From 276241447c3699f56704d286d6e343adcf019867 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Feb 2026 21:46:47 -0500 Subject: [PATCH 001/114] fix: add deprecation warning for setup() --- lua/cp/init.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index fac3044..0bb5582 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -34,4 +34,13 @@ function M.is_initialized() return initialized end +---@deprecated Use `vim.g.cp_config` instead +function M.setup(user_config) + vim.deprecate('require("cp").setup()', 'vim.g.cp_config', 'v0.1.0', 'cp.nvim', false) + + if user_config then + vim.g.cp_config = vim.tbl_deep_extend('force', vim.g.cp_config or {}, user_config) + end +end + return M From a54a06f9396213725a06aab58e023ec6d8e20f37 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 15:12:47 -0500 Subject: [PATCH 002/114] refactor: rename `vim.g.cp_config` to `vim.g.cp` --- doc/cp.nvim.txt | 8 ++++---- lua/cp/init.lua | 12 ++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index d6d1d73..17fd18c 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -205,9 +205,9 @@ Debug Builds ~ ============================================================================== CONFIGURATION *cp-config* -Configuration is done via `vim.g.cp_config`. Set this before using the plugin: +Configuration is done via `vim.g.cp`. Set this before using the plugin: >lua - vim.g.cp_config = { + vim.g.cp = { languages = { cpp = { extension = 'cc', @@ -274,7 +274,7 @@ the default; per-platform overrides can tweak 'extension' or 'commands'. For example, to run CodeForces contests with Python by default: >lua - vim.g.cp_config = { + vim.g.cp = { platforms = { codeforces = { default_language = 'python', @@ -285,7 +285,7 @@ For example, to run CodeForces contests with Python by default: Any language is supported provided the proper configuration. For example, to run CSES problems with Rust using the single schema: >lua - vim.g.cp_config = { + vim.g.cp = { languages = { rust = { extension = 'rs', diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 0bb5582..1837901 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -17,7 +17,11 @@ local function ensure_initialized() if initialized then return end - local user_config = vim.g.cp_config or {} + if vim.g.cp_config then + vim.deprecate('vim.g.cp_config', 'vim.g.cp', 'v0.7.6', 'cp.nvim', false) + vim.g.cp = vim.g.cp or vim.g.cp_config + end + local user_config = vim.g.cp or {} local config = config_module.setup(user_config) config_module.set_current_config(config) initialized = true @@ -34,12 +38,12 @@ function M.is_initialized() return initialized end ----@deprecated Use `vim.g.cp_config` instead +---@deprecated Use `vim.g.cp` instead function M.setup(user_config) - vim.deprecate('require("cp").setup()', 'vim.g.cp_config', 'v0.1.0', 'cp.nvim', false) + vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.1.0', 'cp.nvim', false) if user_config then - vim.g.cp_config = vim.tbl_deep_extend('force', vim.g.cp_config or {}, user_config) + vim.g.cp = vim.tbl_deep_extend('force', vim.g.cp or {}, user_config) end end From d23b4e59d15018472a3eb6c397f7e74cf2a6eaa9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 16:29:46 -0500 Subject: [PATCH 003/114] fix(setup): set language state before setup_code hook on first open Problem: when opening a contest for the first time (metadata not cached), the setup_code hook fired before state.set_language() was called, causing state.get_language() to return nil inside the hook. Solution: call state.set_language(lang) before the hook in the provisional-buffer branch of setup_contest(). The value is already computed at that point and is identical to what setup_problem() sets later, so the early write is idempotent. --- lua/cp/setup.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index fce1a0c..e3bb38d 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -160,6 +160,8 @@ function M.setup_contest(platform, contest_id, problem_id, language) vim.bo[bufnr].buftype = '' vim.bo[bufnr].swapfile = false + state.set_language(lang) + if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then local ok = pcall(cfg.hooks.setup_code, state) if ok then From de2bc0753228be518d0cf0a6d5614c281d5b3144 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Feb 2026 16:40:39 -0500 Subject: [PATCH 004/114] refactor: remove vim.g.cp_config compatibility shim Problem: the deprecated vim.g.cp_config fallback was kept for backwards compatibility after the rename to vim.g.cp in v0.7.6. Solution: drop the shim entirely and update the setup() deprecation target to v0.7.7. --- lua/cp/init.lua | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 1837901..3cb36c1 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -17,10 +17,6 @@ local function ensure_initialized() if initialized then return end - if vim.g.cp_config then - vim.deprecate('vim.g.cp_config', 'vim.g.cp', 'v0.7.6', 'cp.nvim', false) - vim.g.cp = vim.g.cp or vim.g.cp_config - end local user_config = vim.g.cp or {} local config = config_module.setup(user_config) config_module.set_current_config(config) @@ -40,7 +36,7 @@ end ---@deprecated Use `vim.g.cp` instead function M.setup(user_config) - vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.1.0', 'cp.nvim', false) + vim.deprecate('require("cp").setup()', 'vim.g.cp', 'v0.7.7', 'cp.nvim', false) if user_config then vim.g.cp = vim.tbl_deep_extend('force', vim.g.cp or {}, user_config) From 029ea125b97320ff5c2884bf84bf5aa4e7077c79 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 13:22:19 -0500 Subject: [PATCH 005/114] feat: add mappings for all primary actions Problem: users who want keybindings must call vim.cmd('CP run') or reach into internal Lua modules directly. There is no stable, discoverable, lazy-load-friendly public API for key binding. Solution: define 7 mappings in plugin/cp.lua that dispatch through the same handle_command() code path as :CP. Document them in a new MAPPINGS section in the vimdoc with helptags and an example config block. --- doc/cp.nvim.txt | 37 +++++++++++++++++++++++++++++++++++++ plugin/cp.lua | 14 ++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 17fd18c..9361121 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -202,6 +202,43 @@ Debug Builds ~ } < +============================================================================== +MAPPINGS *cp-mappings* + +cp.nvim provides mappings for all primary actions. These dispatch +through the same code path as |:CP|. + + *(cp-run)* +(cp-run) Run tests in I/O view. Equivalent to :CP run. + + *(cp-panel)* +(cp-panel) Open full-screen test panel. Equivalent to :CP panel. + + *(cp-edit)* +(cp-edit) Open the test case editor. Equivalent to :CP edit. + + *(cp-next)* +(cp-next) Navigate to the next problem. Equivalent to :CP next. + + *(cp-prev)* +(cp-prev) Navigate to the previous problem. Equivalent to :CP prev. + + *(cp-pick)* +(cp-pick) Launch the contest picker. Equivalent to :CP pick. + + *(cp-interact)* +(cp-interact) Open interactive mode. Equivalent to :CP interact. + +Example configuration: >lua + vim.keymap.set('n', 'cr', '(cp-run)') + vim.keymap.set('n', 'cp', '(cp-panel)') + vim.keymap.set('n', 'ce', '(cp-edit)') + vim.keymap.set('n', 'cn', '(cp-next)') + vim.keymap.set('n', 'cN', '(cp-prev)') + vim.keymap.set('n', 'cc', '(cp-pick)') + vim.keymap.set('n', 'ci', '(cp-interact)') +< + ============================================================================== CONFIGURATION *cp-config* diff --git a/plugin/cp.lua b/plugin/cp.lua index b91a3d5..2689e71 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -154,3 +154,17 @@ end, { return {} end, }) + +local function cp_action(action) + return function() + require('cp').handle_command({ fargs = { action } }) + end +end + +vim.keymap.set('n', '(cp-run)', cp_action('run'), { desc = 'CP run tests' }) +vim.keymap.set('n', '(cp-panel)', cp_action('panel'), { desc = 'CP open panel' }) +vim.keymap.set('n', '(cp-edit)', cp_action('edit'), { desc = 'CP edit test cases' }) +vim.keymap.set('n', '(cp-next)', cp_action('next'), { desc = 'CP next problem' }) +vim.keymap.set('n', '(cp-prev)', cp_action('prev'), { desc = 'CP previous problem' }) +vim.keymap.set('n', '(cp-pick)', cp_action('pick'), { desc = 'CP pick contest' }) +vim.keymap.set('n', '(cp-interact)', cp_action('interact'), { desc = 'CP interactive mode' }) From 49df7e015d9c37b3e8ded3842a85d18878a7150e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 17 Feb 2026 20:47:52 -0500 Subject: [PATCH 006/114] docs: add setup section and reorder vimdoc Problem: the vimdoc had no setup section, and configuration was buried after commands and mappings. Solution: add a cp-setup section with lazy.nvim example and move both setup and configuration above commands for better discoverability. --- doc/cp.nvim.txt | 444 ++++++++++++++++++++++++------------------------ 1 file changed, 226 insertions(+), 218 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 9361121..e85ea45 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -19,225 +19,13 @@ REQUIREMENTS *cp-requirements* - uv package manager (https://docs.astral.sh/uv/) ============================================================================== -COMMANDS *cp-commands* +SETUP *cp-setup* -:CP *:CP* - cp.nvim uses a single :CP command with intelligent argument parsing: - - Setup Commands ~ - :CP {platform} {contest_id} [--lang {language}] - Full setup: set platform and load contest metadata. - Scrapes test cases and creates source file. - --lang: Use specific language (default: platform default) - Examples: > - :CP codeforces 1933 - :CP codeforces 1933 --lang python -< - View Commands ~ - :CP run [all|n|n,m,...] [--debug] - Run tests in I/O view (see |cp-io-view|). - Lightweight split showing test verdicts. - - Execution modes: - • :CP run Combined: single execution with all tests - (auto-switches to individual when multiple samples) - • :CP run all Individual: N separate executions - • :CP run n Individual: run test n only - • :CP run n,m,... Individual: run specific tests (e.g. nth and mth) - - --debug: Use debug build (builds to build/.dbg) - - Combined mode runs all test inputs in one execution (matching - platform behavior for multi-test problems). When a problem has - multiple independent sample test cases, :CP run auto-switches to - individual mode to run each sample separately. - - Examples: > - :CP run " Combined: all tests, one execution - :CP run all " Individual: all tests, N executions - :CP run 2 " Individual: test 2 only - :CP run 1,3,5 " Individual: tests 1, 3, and 5 - :CP run all --debug " Individual with debug build -< - :CP panel [--debug] [n] - Open full-screen test panel (see |cp-panel|). - Aggregate table with diff modes for detailed analysis. - Optional [n] focuses on specific test. - --debug: Use debug build (with sanitizers, etc.) - Examples: > - :CP panel " All tests - :CP panel --debug 3 " Test 3, debug build -< - - :CP pick [--lang {language}] - Launch configured picker for interactive - platform/contest selection. - --lang: Pre-select language for chosen contest. - Example: > - :CP pick - :CP pick --lang python -< - - :CP interact [script] - Open an interactive terminal for the current problem. - If an executable interactor is provided, runs the compiled - binary against the source file (see - *cp-interact*). Otherwise, runs the source - file. Only valid for interactive problems. - - Navigation Commands ~ - :CP next [--lang {language}] - Navigate to next problem in current contest. - Stops at last problem (no wrapping). - --lang: Use specific language for next problem. - By default, preserves current file's language if - enabled for the new problem, otherwise uses platform - default. - Examples: > - :CP next - :CP next --lang python -< - :CP prev [--lang {language}] - Navigate to previous problem in current contest. - Stops at first problem (no wrapping). - --lang: Use specific language for previous problem. - By default, preserves current file's language if - enabled for the new problem, otherwise uses platform - default. - Examples: > - :CP prev - :CP prev --lang cpp -< - :CP {problem_id} [--lang {language}] - Jump to problem {problem_id} in a contest. - Requires that a contest has already been set up. - --lang: Use specific language for this problem. - Examples: > - :CP B - :CP C --lang python -< - - Edit Commands ~ - :CP edit [n] - Open grid test editor showing all test cases. - Tests displayed as 2×N grid (2 rows, N columns): - • Top row: Test inputs (editable) - • Bottom row: Expected outputs (editable) - - Optional [n]: Jump cursor to test n's input buffer - - Changes saved to both cache and disk on exit, - taking effect immediately in :CP run and CLI. - - Keybindings (configurable via |EditConfig|): - q Save all and exit editor - ]t Jump to next test column - [t Jump to previous test column - gd Delete current test column - ga Add new test column at end - Normal window navigation - - Examples: > - :CP edit " Edit all tests - :CP edit 3 " Edit all, start at test 3 -< - - State Restoration ~ - :CP Restore state from current file. - Automatically detects platform, contest, problem, - and language from cached state. Use this after - switching files to restore your CP environment. - - Cache Commands ~ - :CP cache clear [platform] [contest] - Clear cache data at different granularities: - • No args: Clear all cached data - • [platform]: Clear all data for a platform - • [platform] [contest]: Clear specific contest - Examples: > - :CP cache clear - :CP cache clear codeforces - :CP cache clear codeforces 1848 -< - :CP cache read - View the cache in a pretty-printed lua buffer. - Exit with q. - -Template Variables ~ - *cp-template-vars* - Command templates support variable substitution using {variable} syntax: - - • {source} Source file path (e.g. "abc324a.cpp") - • {binary} Output binary path (e.g. "build/abc324a.run" or - "build/abc324a.dbg" for debug builds) - - Example template: > - build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' } -< Would expand to: > - g++ abc324a.cpp -o build/abc324a.run -std=c++17 -< -Debug Builds ~ - *cp-debug-builds* - The --debug flag uses the debug command configuration instead of build: - - • Normal build: commands.build → outputs to build/.run - • Debug build: commands.debug → outputs to build/.dbg - - Debug builds typically include sanitizers (address, undefined behavior) to - catch memory errors, buffer overflows, and other issues. Both binaries - coexist, so you can switch between normal and debug mode without - recompiling. - - Example debug configuration: > - languages = { - cpp = { - extension = 'cc', - commands = { - build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' }, - run = { '{binary}' }, - debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined', - '{source}', '-o', '{binary}' }, - } - } - } -< - -============================================================================== -MAPPINGS *cp-mappings* - -cp.nvim provides mappings for all primary actions. These dispatch -through the same code path as |:CP|. - - *(cp-run)* -(cp-run) Run tests in I/O view. Equivalent to :CP run. - - *(cp-panel)* -(cp-panel) Open full-screen test panel. Equivalent to :CP panel. - - *(cp-edit)* -(cp-edit) Open the test case editor. Equivalent to :CP edit. - - *(cp-next)* -(cp-next) Navigate to the next problem. Equivalent to :CP next. - - *(cp-prev)* -(cp-prev) Navigate to the previous problem. Equivalent to :CP prev. - - *(cp-pick)* -(cp-pick) Launch the contest picker. Equivalent to :CP pick. - - *(cp-interact)* -(cp-interact) Open interactive mode. Equivalent to :CP interact. - -Example configuration: >lua - vim.keymap.set('n', 'cr', '(cp-run)') - vim.keymap.set('n', 'cp', '(cp-panel)') - vim.keymap.set('n', 'ce', '(cp-edit)') - vim.keymap.set('n', 'cn', '(cp-next)') - vim.keymap.set('n', 'cN', '(cp-prev)') - vim.keymap.set('n', 'cc', '(cp-pick)') - vim.keymap.set('n', 'ci', '(cp-interact)') +Load cp.nvim with your package manager. For example, with lazy.nvim: >lua + { 'barrettruth/cp.nvim' } < +The plugin works automatically with no configuration required. For +customization, see |cp-config|. ============================================================================== CONFIGURATION *cp-config* @@ -462,12 +250,232 @@ run CSES problems with Rust using the single schema: print("Source file: " .. state.get_source_file()) end, setup_io_input = function(bufnr, state) - -- Custom setup for input buffer vim.api.nvim_set_option_value('number', false, { buf = bufnr }) end } < +============================================================================== +COMMANDS *cp-commands* + +:CP *:CP* + cp.nvim uses a single :CP command with intelligent argument parsing: + + Setup Commands ~ + :CP {platform} {contest_id} [--lang {language}] + Full setup: set platform and load contest metadata. + Scrapes test cases and creates source file. + --lang: Use specific language (default: platform default) + Examples: > + :CP codeforces 1933 + :CP codeforces 1933 --lang python +< + View Commands ~ + :CP run [all|n|n,m,...] [--debug] + Run tests in I/O view (see |cp-io-view|). + Lightweight split showing test verdicts. + + Execution modes: + • :CP run Combined: single execution with all tests + (auto-switches to individual when multiple samples) + • :CP run all Individual: N separate executions + • :CP run n Individual: run test n only + • :CP run n,m,... Individual: run specific tests (e.g. nth and mth) + + --debug: Use debug build (builds to build/.dbg) + + Combined mode runs all test inputs in one execution (matching + platform behavior for multi-test problems). When a problem has + multiple independent sample test cases, :CP run auto-switches to + individual mode to run each sample separately. + + Examples: > + :CP run " Combined: all tests, one execution + :CP run all " Individual: all tests, N executions + :CP run 2 " Individual: test 2 only + :CP run 1,3,5 " Individual: tests 1, 3, and 5 + :CP run all --debug " Individual with debug build +< + :CP panel [--debug] [n] + Open full-screen test panel (see |cp-panel|). + Aggregate table with diff modes for detailed analysis. + Optional [n] focuses on specific test. + --debug: Use debug build (with sanitizers, etc.) + Examples: > + :CP panel " All tests + :CP panel --debug 3 " Test 3, debug build +< + + :CP pick [--lang {language}] + Launch configured picker for interactive + platform/contest selection. + --lang: Pre-select language for chosen contest. + Example: > + :CP pick + :CP pick --lang python +< + + :CP interact [script] + Open an interactive terminal for the current problem. + If an executable interactor is provided, runs the compiled + binary against the source file (see + *cp-interact*). Otherwise, runs the source + file. Only valid for interactive problems. + + Navigation Commands ~ + :CP next [--lang {language}] + Navigate to next problem in current contest. + Stops at last problem (no wrapping). + --lang: Use specific language for next problem. + By default, preserves current file's language if + enabled for the new problem, otherwise uses platform + default. + Examples: > + :CP next + :CP next --lang python +< + :CP prev [--lang {language}] + Navigate to previous problem in current contest. + Stops at first problem (no wrapping). + --lang: Use specific language for previous problem. + By default, preserves current file's language if + enabled for the new problem, otherwise uses platform + default. + Examples: > + :CP prev + :CP prev --lang cpp +< + :CP {problem_id} [--lang {language}] + Jump to problem {problem_id} in a contest. + Requires that a contest has already been set up. + --lang: Use specific language for this problem. + Examples: > + :CP B + :CP C --lang python +< + + Edit Commands ~ + :CP edit [n] + Open grid test editor showing all test cases. + Tests displayed as 2×N grid (2 rows, N columns): + • Top row: Test inputs (editable) + • Bottom row: Expected outputs (editable) + + Optional [n]: Jump cursor to test n's input buffer + + Changes saved to both cache and disk on exit, + taking effect immediately in :CP run and CLI. + + Keybindings (configurable via |EditConfig|): + q Save all and exit editor + ]t Jump to next test column + [t Jump to previous test column + gd Delete current test column + ga Add new test column at end + Normal window navigation + + Examples: > + :CP edit " Edit all tests + :CP edit 3 " Edit all, start at test 3 +< + + State Restoration ~ + :CP Restore state from current file. + Automatically detects platform, contest, problem, + and language from cached state. Use this after + switching files to restore your CP environment. + + Cache Commands ~ + :CP cache clear [platform] [contest] + Clear cache data at different granularities: + • No args: Clear all cached data + • [platform]: Clear all data for a platform + • [platform] [contest]: Clear specific contest + Examples: > + :CP cache clear + :CP cache clear codeforces + :CP cache clear codeforces 1848 +< + :CP cache read + View the cache in a pretty-printed lua buffer. + Exit with q. + +Template Variables ~ + *cp-template-vars* + Command templates support variable substitution using {variable} syntax: + + • {source} Source file path (e.g. "abc324a.cpp") + • {binary} Output binary path (e.g. "build/abc324a.run" or + "build/abc324a.dbg" for debug builds) + + Example template: > + build = { 'g++', '{source}', '-o', '{binary}', '-std=c++17' } +< Would expand to: > + g++ abc324a.cpp -o build/abc324a.run -std=c++17 +< +Debug Builds ~ + *cp-debug-builds* + The --debug flag uses the debug command configuration instead of build: + + • Normal build: commands.build → outputs to build/.run + • Debug build: commands.debug → outputs to build/.dbg + + Debug builds typically include sanitizers (address, undefined behavior) to + catch memory errors, buffer overflows, and other issues. Both binaries + coexist, so you can switch between normal and debug mode without + recompiling. + + Example debug configuration: > + languages = { + cpp = { + extension = 'cc', + commands = { + build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}' }, + run = { '{binary}' }, + debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined', + '{source}', '-o', '{binary}' }, + } + } + } +< + +============================================================================== +MAPPINGS *cp-mappings* + +cp.nvim provides mappings for all primary actions. These dispatch +through the same code path as |:CP|. + + *(cp-run)* +(cp-run) Run tests in I/O view. Equivalent to :CP run. + + *(cp-panel)* +(cp-panel) Open full-screen test panel. Equivalent to :CP panel. + + *(cp-edit)* +(cp-edit) Open the test case editor. Equivalent to :CP edit. + + *(cp-next)* +(cp-next) Navigate to the next problem. Equivalent to :CP next. + + *(cp-prev)* +(cp-prev) Navigate to the previous problem. Equivalent to :CP prev. + + *(cp-pick)* +(cp-pick) Launch the contest picker. Equivalent to :CP pick. + + *(cp-interact)* +(cp-interact) Open interactive mode. Equivalent to :CP interact. + +Example configuration: >lua + vim.keymap.set('n', 'cr', '(cp-run)') + vim.keymap.set('n', 'cp', '(cp-panel)') + vim.keymap.set('n', 'ce', '(cp-edit)') + vim.keymap.set('n', 'cn', '(cp-next)') + vim.keymap.set('n', 'cN', '(cp-prev)') + vim.keymap.set('n', 'cc', '(cp-pick)') + vim.keymap.set('n', 'ci', '(cp-interact)') +< + ============================================================================== LANGUAGE SELECTION *cp-lang-selection* From 51504b0121ba7231d529fdde976d631d2c4079f7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 17 Feb 2026 21:10:29 -0500 Subject: [PATCH 007/114] fix: flake config; --- .envrc | 3 --- .gitignore | 3 +++ flake.lock | 27 +++++++++++++++++++++++++++ flake.nix | 12 ++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) delete mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc deleted file mode 100644 index aa57334..0000000 --- a/.envrc +++ /dev/null @@ -1,3 +0,0 @@ -VIRTUAL_ENV="$PWD/.venv" -PATH_add "$VIRTUAL_ENV/bin" -export VIRTUAL_ENV diff --git a/.gitignore b/.gitignore index 45bc345..a489c55 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ __pycache__ .claude/ node_modules/ + +.envrc +.direnv/ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5dcdbd4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771008912, + "narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a82ccc39b39b621151d6732718e3e250109076fa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..583a505 --- /dev/null +++ b/flake.nix @@ -0,0 +1,12 @@ +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: { + devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell { + packages = with nixpkgs.legacyPackages.x86_64-linux; [ + uv + python312 + ]; + }; + }; +} From da433068ef666730c87530d4f4fd18c7659abd63 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 17 Feb 2026 21:10:56 -0500 Subject: [PATCH 008/114] remove straggler file --- new | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 new diff --git a/new b/new deleted file mode 100644 index e69de29..0000000 From 04d0c124cfbe9a63c7e49bb6b38f179a80a4e061 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 17 Feb 2026 21:11:11 -0500 Subject: [PATCH 009/114] fix: remove flake config --- flake.lock | 27 --------------------------- flake.nix | 12 ------------ 2 files changed, 39 deletions(-) delete mode 100644 flake.lock delete mode 100644 flake.nix diff --git a/flake.lock b/flake.lock deleted file mode 100644 index 5dcdbd4..0000000 --- a/flake.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1771008912, - "narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "a82ccc39b39b621151d6732718e3e250109076fa", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 583a505..0000000 --- a/flake.nix +++ /dev/null @@ -1,12 +0,0 @@ -{ - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - outputs = { self, nixpkgs }: { - devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell { - packages = with nixpkgs.legacyPackages.x86_64-linux; [ - uv - python312 - ]; - }; - }; -} From b36ffba63a8ee0ed47ab51385129a52f11cbefb0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 17 Feb 2026 21:11:29 -0500 Subject: [PATCH 010/114] feat(nix): initial flake config; --- flake.lock | 27 +++++++++++++++++++++++++++ flake.nix | 12 ++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5dcdbd4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771008912, + "narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a82ccc39b39b621151d6732718e3e250109076fa", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..583a505 --- /dev/null +++ b/flake.nix @@ -0,0 +1,12 @@ +{ + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: { + devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell { + packages = with nixpkgs.legacyPackages.x86_64-linux; [ + uv + python312 + ]; + }; + }; +} From 1162e7046be9bf0a256fb2afcbfdfd3c6700ab69 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 13:33:49 -0500 Subject: [PATCH 011/114] try to fix the setup --- flake.lock | 18 +- flake.nix | 78 ++- lua/cp/health.lua | 35 +- lua/cp/scraper.lua | 7 +- lua/cp/ui/views.lua | 23 +- lua/cp/utils.lua | 129 +++- pyproject.toml | 2 - scrapers/codechef.py | 7 +- scrapers/codeforces.py | 14 +- tests/conftest.py | 33 +- uv.lock | 1269 ---------------------------------------- 11 files changed, 256 insertions(+), 1359 deletions(-) diff --git a/flake.lock b/flake.lock index 5dcdbd4..224b38d 100644 --- a/flake.lock +++ b/flake.lock @@ -18,7 +18,23 @@ }, "root": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1689347949, + "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=", + "owner": "nix-systems", + "repo": "default-linux", + "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default-linux", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 583a505..705747f 100644 --- a/flake.nix +++ b/flake.nix @@ -1,12 +1,72 @@ { - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - outputs = { self, nixpkgs }: { - devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell { - packages = with nixpkgs.legacyPackages.x86_64-linux; [ - uv - python312 - ]; - }; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + systems.url = "github:nix-systems/default-linux"; }; + + outputs = + { + self, + nixpkgs, + systems, + }: + let + eachSystem = nixpkgs.lib.genAttrs (import systems); + pkgsFor = system: nixpkgs.legacyPackages.${system}; + + mkPythonEnv = + pkgs: + pkgs.python312.withPackages (ps: [ + ps.backoff + ps.beautifulsoup4 + ps.curl-cffi + ps.httpx + ps.ndjson + ps.pydantic + ps.requests + ]); + + mkPlugin = + pkgs: + let + pythonEnv = mkPythonEnv pkgs; + in + pkgs.vimUtils.buildVimPlugin { + pname = "cp-nvim"; + version = "0-unstable-${self.shortRev or self.dirtyShortRev or "dev"}"; + src = self; + postPatch = '' + substituteInPlace lua/cp/utils.lua \ + --replace-fail "local _nix_python = nil" \ + "local _nix_python = '${pythonEnv.interpreter}'" + ''; + nvimSkipModule = [ + "cp.pickers.telescope" + "cp.version" + ]; + passthru = { inherit pythonEnv; }; + meta.description = "Competitive programming plugin for Neovim"; + }; + in + { + overlays.default = final: prev: { + vimPlugins = prev.vimPlugins // { + cp-nvim = mkPlugin final; + }; + }; + + packages = eachSystem (system: { + default = mkPlugin (pkgsFor system); + pythonEnv = mkPythonEnv (pkgsFor system); + }); + + devShells = eachSystem (system: { + default = (pkgsFor system).mkShell { + packages = with (pkgsFor system); [ + uv + python312 + ]; + }; + }); + }; } diff --git a/lua/cp/health.lua b/lua/cp/health.lua index ba3879a..6a3913f 100644 --- a/lua/cp/health.lua +++ b/lua/cp/health.lua @@ -5,6 +5,8 @@ local utils = require('cp.utils') local function check() vim.health.start('cp.nvim [required] ~') + utils.setup_python_env() + if vim.fn.has('nvim-0.10.0') == 1 then vim.health.ok('Neovim 0.10.0+ detected') else @@ -16,22 +18,31 @@ local function check() vim.health.error('Windows is not supported') end - if vim.fn.executable('uv') == 1 then - vim.health.ok('uv executable found') - local r = vim.system({ 'uv', '--version' }, { text = true }):wait() + if utils.is_nix_build() then + vim.health.ok('Nix-built Python environment detected') + local py = utils.get_nix_python() + local r = vim.system({ py, '--version' }, { text = true }):wait() if r.code == 0 then - vim.health.info('uv version: ' .. r.stdout:gsub('\n', '')) + vim.health.info('Python: ' .. r.stdout:gsub('\n', '')) end else - vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)') - end + if vim.fn.executable('uv') == 1 then + vim.health.ok('uv executable found') + local r = vim.system({ 'uv', '--version' }, { text = true }):wait() + if r.code == 0 then + vim.health.info('uv version: ' .. r.stdout:gsub('\n', '')) + end + else + vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)') + end - local plugin_path = utils.get_plugin_path() - local venv_dir = plugin_path .. '/.venv' - if vim.fn.isdirectory(venv_dir) == 1 then - vim.health.ok('Python virtual environment found at ' .. venv_dir) - else - vim.health.info('Python virtual environment not set up (created on first scrape)') + local plugin_path = utils.get_plugin_path() + local venv_dir = plugin_path .. '/.venv' + if vim.fn.isdirectory(venv_dir) == 1 then + vim.health.ok('Python virtual environment found at ' .. venv_dir) + else + vim.health.info('Python virtual environment not set up (created on first scrape)') + end end local time_cap = utils.time_capability() diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index c42d8be..4771cc1 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -26,7 +26,8 @@ end ---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) } local function run_scraper(platform, subcommand, args, opts) local plugin_path = utils.get_plugin_path() - local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand } + local cmd = utils.get_python_cmd(platform, plugin_path) + vim.list_extend(cmd, { subcommand }) vim.list_extend(cmd, args) local env = vim.fn.environ() @@ -43,7 +44,7 @@ local function run_scraper(platform, subcommand, args, opts) local handle handle = uv.spawn( cmd[1], - { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env }, + { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env, cwd = plugin_path }, function(code, signal) if buf ~= '' and opts.on_event then local ok_tail, ev_tail = pcall(vim.json.decode, buf) @@ -102,7 +103,7 @@ local function run_scraper(platform, subcommand, args, opts) return end - local sysopts = { text = true, timeout = 30000, env = env } + local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path } if opts and opts.sync then local result = vim.system(cmd, sysopts):wait() return syshandle(result) diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index c1d25fc..2d67569 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -121,13 +121,22 @@ function M.toggle_interactive(interactor_cmd) end local orchestrator = vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p') - cmdline = table.concat({ - 'uv', - 'run', - vim.fn.shellescape(orchestrator), - vim.fn.shellescape(interactor), - vim.fn.shellescape(binary), - }, ' ') + if utils.is_nix_build() then + cmdline = table.concat({ + vim.fn.shellescape(utils.get_nix_python()), + vim.fn.shellescape(orchestrator), + vim.fn.shellescape(interactor), + vim.fn.shellescape(binary), + }, ' ') + else + cmdline = table.concat({ + 'uv', + 'run', + vim.fn.shellescape(orchestrator), + vim.fn.shellescape(interactor), + vim.fn.shellescape(binary), + }, ' ') + end else cmdline = vim.fn.shellescape(binary) end diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 654c2ef..0ac4916 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -2,6 +2,8 @@ local M = {} local logger = require('cp.log') +local _nix_python = nil + local uname = vim.loop.os_uname() local _time_cached = false @@ -79,43 +81,116 @@ function M.get_plugin_path() return vim.fn.fnamemodify(plugin_path, ':h:h:h') end +function M.is_nix_build() + return _nix_python ~= nil +end + +function M.get_nix_python() + return _nix_python +end + +function M.get_python_cmd(module, plugin_path) + if _nix_python then + return { _nix_python, '-m', 'scrapers.' .. module } + end + return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module } +end + local python_env_setup = false +local function discover_nix_python() + local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim' + local cache_file = cache_dir .. '/nix-python' + + local f = io.open(cache_file, 'r') + if f then + local cached = f:read('*l') + f:close() + if cached and vim.fn.executable(cached) == 1 then + _nix_python = cached + return true + end + end + + local plugin_path = M.get_plugin_path() + local result = vim + .system( + { 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' }, + { text = true } + ) + :wait() + + if result.code ~= 0 then + logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN) + return false + end + + local store_path = result.stdout:gsub('%s+$', '') + local python_path = store_path .. '/bin/python3' + + if vim.fn.executable(python_path) ~= 1 then + logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN) + return false + end + + vim.fn.mkdir(cache_dir, 'p') + f = io.open(cache_file, 'w') + if f then + f:write(python_path) + f:close() + end + + _nix_python = python_path + return true +end + ---@return boolean success function M.setup_python_env() if python_env_setup then return true end - local plugin_path = M.get_plugin_path() - local venv_dir = plugin_path .. '/.venv' - - if vim.fn.executable('uv') == 0 then - logger.log( - 'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/', - vim.log.levels.WARN - ) - return false + if _nix_python then + python_env_setup = true + return true end - if vim.fn.isdirectory(venv_dir) == 0 then - logger.log('Setting up Python environment for scrapers...') - local env = vim.fn.environ() - env.VIRTUAL_ENV = '' - env.PYTHONPATH = '' - env.CONDA_PREFIX = '' - local result = vim - .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) - :wait() - if result.code ~= 0 then - logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) - return false + if vim.fn.executable('uv') == 1 then + local plugin_path = M.get_plugin_path() + local venv_dir = plugin_path .. '/.venv' + + if vim.fn.isdirectory(venv_dir) == 0 then + logger.log('Setting up Python environment for scrapers...') + local env = vim.fn.environ() + env.VIRTUAL_ENV = '' + env.PYTHONPATH = '' + env.CONDA_PREFIX = '' + local result = vim + .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) + :wait() + if result.code ~= 0 then + logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) + return false + end + logger.log('Python environment setup complete.') end - logger.log('Python environment setup complete.') + + python_env_setup = true + return true end - python_env_setup = true - return true + if vim.fn.executable('nix') == 1 then + if discover_nix_python() then + python_env_setup = true + return true + end + end + + logger.log( + 'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.', + vim.log.levels.WARN + ) + return false end --- Configure the buffer with good defaults @@ -170,12 +245,8 @@ function M.check_required_runtime() return false, 'GNU timeout not found: ' .. (timeout.reason or '') end - if vim.fn.executable('uv') ~= 1 then - return false, 'uv not found (https://docs.astral.sh/uv/)' - end - if not M.setup_python_env() then - return false, 'failed to set up Python virtual environment' + return false, 'no Python environment available (install uv or nix)' end return true diff --git a/pyproject.toml b/pyproject.toml index 44e46a4..b18e7aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,6 @@ dependencies = [ "ndjson>=0.3.1", "pydantic>=2.11.10", "requests>=2.32.5", - "scrapling[fetchers]>=0.3.5", - "types-requests>=2.32.4.20250913", ] [dependency-groups] diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 0687c1e..46c2a1c 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -6,7 +6,7 @@ import re from typing import Any import httpx -from scrapling.fetchers import Fetcher +from curl_cffi import requests as curl_requests from .base import BaseScraper from .models import ( @@ -50,8 +50,9 @@ def _extract_memory_limit(html: str) -> float: def _fetch_html_sync(url: str) -> str: - response = Fetcher.get(url) - return str(response.body) + response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S) + response.raise_for_status() + return response.text class CodeChefScraper(BaseScraper): diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index cf172b8..1d01b8d 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -2,13 +2,12 @@ import asyncio import json -import logging import re from typing import Any import requests from bs4 import BeautifulSoup, Tag -from scrapling.fetchers import Fetcher +from curl_cffi import requests as curl_requests from .base import BaseScraper from .models import ( @@ -19,10 +18,6 @@ from .models import ( TestCase, ) -# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31) -logging.getLogger("scrapling").setLevel(logging.CRITICAL) - - BASE_URL = "https://codeforces.com" API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list" TIMEOUT_SECONDS = 30 @@ -140,10 +135,9 @@ def _is_interactive(block: Tag) -> bool: def _fetch_problems_html(contest_id: str) -> str: url = f"{BASE_URL}/contest/{contest_id}/problems" - page = Fetcher.get( - url, - ) - return page.html_content + response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS) + response.raise_for_status() + return response.text def _parse_all_blocks(html: str) -> list[dict[str, Any]]: diff --git a/tests/conftest.py b/tests/conftest.py index aaefec8..deb7e3a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from typing import Any import httpx import pytest import requests -from scrapling import fetchers +from curl_cffi import requests as curl_requests ROOT = Path(__file__).resolve().parent.parent FIX = Path(__file__).resolve().parent / "fixtures" @@ -136,12 +136,15 @@ def run_scraper_offline(fixture_text): case "codeforces": - class MockCodeForcesPage: + class MockCurlResponse: def __init__(self, html: str): - self.html_content = html + self.text = html - def _mock_stealthy_fetch(url: str, **kwargs): - return MockCodeForcesPage(_router_codeforces(url=url)) + def raise_for_status(self): + pass + + def _mock_curl_get(url: str, **kwargs): + return MockCurlResponse(_router_codeforces(url=url)) def _mock_requests_get(url: str, **kwargs): if "api/contest.list" in url: @@ -172,7 +175,7 @@ def run_scraper_offline(fixture_text): raise AssertionError(f"Unexpected requests.get call: {url}") return { - "Fetcher.get": _mock_stealthy_fetch, + "curl_requests.get": _mock_curl_get, "requests.get": _mock_requests_get, } @@ -212,21 +215,23 @@ def run_scraper_offline(fixture_text): return MockResponse(data) raise AssertionError(f"No fixture for CodeChef url={url!r}") - class MockCodeChefPage: + class MockCodeChefCurlResponse: def __init__(self, html: str): - self.body = html - self.status = 200 + self.text = html - def _mock_stealthy_fetch(url: str, **kwargs): + def raise_for_status(self): + pass + + def _mock_curl_get(url: str, **kwargs): if "/problems/" in url: problem_id = url.rstrip("/").split("/")[-1] html = fixture_text(f"codechef/{problem_id}.html") - return MockCodeChefPage(html) + return MockCodeChefCurlResponse(html) raise AssertionError(f"No fixture for CodeChef url={url!r}") return { "__offline_get_async": __offline_get_async, - "Fetcher.get": _mock_stealthy_fetch, + "curl_requests.get": _mock_curl_get, } case _: @@ -245,7 +250,7 @@ def run_scraper_offline(fixture_text): offline_fetches = _make_offline_fetches(scraper_name) if scraper_name == "codeforces": - fetchers.Fetcher.get = offline_fetches["Fetcher.get"] + curl_requests.get = offline_fetches["curl_requests.get"] requests.get = offline_fetches["requests.get"] elif scraper_name == "atcoder": ns._fetch = offline_fetches["_fetch"] @@ -254,7 +259,7 @@ def run_scraper_offline(fixture_text): httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] elif scraper_name == "codechef": httpx.AsyncClient.get = offline_fetches["__offline_get_async"] - fetchers.Fetcher.get = offline_fetches["Fetcher.get"] + curl_requests.get = offline_fetches["curl_requests.get"] scraper_class = getattr(ns, scraper_classes[scraper_name]) scraper = scraper_class() diff --git a/uv.lock b/uv.lock index 01156d6..66bd81e 100644 --- a/uv.lock +++ b/uv.lock @@ -2,130 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.11" -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.13.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/74/b321e7d7ca762638cdf8cdeceb39755d9c745aff7a64c8789be96ddf6e96/aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", size = 743409, upload-time = "2025-10-28T20:56:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/99/3d/91524b905ec473beaf35158d17f82ef5a38033e5809fe8742e3657cdbb97/aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", size = 497006, upload-time = "2025-10-28T20:56:01.85Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d3/7f68bc02a67716fe80f063e19adbd80a642e30682ce74071269e17d2dba1/aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", size = 493195, upload-time = "2025-10-28T20:56:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/98/31/913f774a4708775433b7375c4f867d58ba58ead833af96c8af3621a0d243/aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", size = 1747759, upload-time = "2025-10-28T20:56:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/e8/63/04efe156f4326f31c7c4a97144f82132c3bb21859b7bb84748d452ccc17c/aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", size = 1704456, upload-time = "2025-10-28T20:56:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/8e/02/4e16154d8e0a9cf4ae76f692941fd52543bbb148f02f098ca73cab9b1c1b/aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", size = 1807572, upload-time = "2025-10-28T20:56:08.558Z" }, - { url = "https://files.pythonhosted.org/packages/34/58/b0583defb38689e7f06798f0285b1ffb3a6fb371f38363ce5fd772112724/aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a", size = 1895954, upload-time = "2025-10-28T20:56:10.545Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/083907ee3437425b4e376aa58b2c915eb1a33703ec0dc30040f7ae3368c6/aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", size = 1747092, upload-time = "2025-10-28T20:56:12.118Z" }, - { url = "https://files.pythonhosted.org/packages/ac/61/98a47319b4e425cc134e05e5f3fc512bf9a04bf65aafd9fdcda5d57ec693/aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", size = 1606815, upload-time = "2025-10-28T20:56:14.191Z" }, - { url = "https://files.pythonhosted.org/packages/97/4b/e78b854d82f66bb974189135d31fce265dee0f5344f64dd0d345158a5973/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", size = 1723789, upload-time = "2025-10-28T20:56:16.101Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fc/9d2ccc794fc9b9acd1379d625c3a8c64a45508b5091c546dea273a41929e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", size = 1718104, upload-time = "2025-10-28T20:56:17.655Z" }, - { url = "https://files.pythonhosted.org/packages/66/65/34564b8765ea5c7d79d23c9113135d1dd3609173da13084830f1507d56cf/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", size = 1785584, upload-time = "2025-10-28T20:56:19.238Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/f6a7a426e02fc82781afd62016417b3948e2207426d90a0e478790d1c8a4/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", size = 1595126, upload-time = "2025-10-28T20:56:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c7/8e22d5d28f94f67d2af496f14a83b3c155d915d1fe53d94b66d425ec5b42/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", size = 1800665, upload-time = "2025-10-28T20:56:22.922Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/91133c8b68b1da9fc16555706aa7276fdf781ae2bb0876c838dd86b8116e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", size = 1739532, upload-time = "2025-10-28T20:56:25.924Z" }, - { url = "https://files.pythonhosted.org/packages/17/6b/3747644d26a998774b21a616016620293ddefa4d63af6286f389aedac844/aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", size = 431876, upload-time = "2025-10-28T20:56:27.524Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/688462108c1a00eb9f05765331c107f95ae86f6b197b865d29e930b7e462/aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", size = 456205, upload-time = "2025-10-28T20:56:29.062Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623, upload-time = "2025-10-28T20:56:30.797Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664, upload-time = "2025-10-28T20:56:32.708Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808, upload-time = "2025-10-28T20:56:34.57Z" }, - { url = "https://files.pythonhosted.org/packages/00/29/8e4609b93e10a853b65f8291e64985de66d4f5848c5637cddc70e98f01f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", size = 1738863, upload-time = "2025-10-28T20:56:36.377Z" }, - { url = "https://files.pythonhosted.org/packages/9d/fa/4ebdf4adcc0def75ced1a0d2d227577cd7b1b85beb7edad85fcc87693c75/aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", size = 1700586, upload-time = "2025-10-28T20:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/da/04/73f5f02ff348a3558763ff6abe99c223381b0bace05cd4530a0258e52597/aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", size = 1768625, upload-time = "2025-10-28T20:56:39.75Z" }, - { url = "https://files.pythonhosted.org/packages/f8/49/a825b79ffec124317265ca7d2344a86bcffeb960743487cb11988ffb3494/aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", size = 1867281, upload-time = "2025-10-28T20:56:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/b9/48/adf56e05f81eac31edcfae45c90928f4ad50ef2e3ea72cb8376162a368f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", size = 1752431, upload-time = "2025-10-28T20:56:43.162Z" }, - { url = "https://files.pythonhosted.org/packages/30/ab/593855356eead019a74e862f21523db09c27f12fd24af72dbc3555b9bfd9/aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", size = 1562846, upload-time = "2025-10-28T20:56:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/39/0f/9f3d32271aa8dc35036e9668e31870a9d3b9542dd6b3e2c8a30931cb27ae/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", size = 1699606, upload-time = "2025-10-28T20:56:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3c/52d2658c5699b6ef7692a3f7128b2d2d4d9775f2a68093f74bca06cf01e1/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", size = 1720663, upload-time = "2025-10-28T20:56:48.528Z" }, - { url = "https://files.pythonhosted.org/packages/9b/d4/8f8f3ff1fb7fb9e3f04fcad4e89d8a1cd8fc7d05de67e3de5b15b33008ff/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", size = 1737939, upload-time = "2025-10-28T20:56:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/03/d3/ddd348f8a27a634daae39a1b8e291ff19c77867af438af844bf8b7e3231b/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", size = 1555132, upload-time = "2025-10-28T20:56:52.568Z" }, - { url = "https://files.pythonhosted.org/packages/39/b8/46790692dc46218406f94374903ba47552f2f9f90dad554eed61bfb7b64c/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", size = 1764802, upload-time = "2025-10-28T20:56:54.292Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e4/19ce547b58ab2a385e5f0b8aa3db38674785085abcf79b6e0edd1632b12f/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", size = 1719512, upload-time = "2025-10-28T20:56:56.428Z" }, - { url = "https://files.pythonhosted.org/packages/70/30/6355a737fed29dcb6dfdd48682d5790cb5eab050f7b4e01f49b121d3acad/aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", size = 426690, upload-time = "2025-10-28T20:56:58.736Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/b10ac09069973d112de6ef980c1f6bb31cb7dcd0bc363acbdad58f927873/aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", size = 453465, upload-time = "2025-10-28T20:57:00.795Z" }, - { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, - { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, - { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, - { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, - { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, - { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, - { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, - { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, - { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, - { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, - { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, - { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, - { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, - { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, - { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, - { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, - { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, - { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, - { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, - { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, - { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, - { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, - { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, - { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, - { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, - { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, - { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, - { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -148,15 +24,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -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" @@ -191,44 +58,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] -[[package]] -name = "browserforge" -version = "1.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/5c/fe4d8cc5d5e61a5b1585190bba19d25bb76c45fdfe9c7bf264f5301fcf33/browserforge-1.2.3.tar.gz", hash = "sha256:d5bec6dffd4748b30fbac9f9c1ef33b26c01a23185240bf90011843e174b7ecc", size = 38072, upload-time = "2025-01-29T09:45:48.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/53/c60eb5bd26cf8689e361031bebc431437bc988555e80ba52d48c12c1d866/browserforge-1.2.3-py3-none-any.whl", hash = "sha256:a6c71ed4688b2f1b0bee757ca82ddad0007cbba68a71eca66ca607dde382f132", size = 39626, upload-time = "2025-01-29T09:45:47.531Z" }, -] - -[[package]] -name = "camoufox" -version = "0.4.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "browserforge" }, - { name = "click" }, - { name = "language-tags" }, - { name = "lxml" }, - { name = "numpy" }, - { name = "orjson" }, - { name = "platformdirs" }, - { name = "playwright" }, - { name = "pysocks" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "screeninfo" }, - { name = "tqdm" }, - { name = "typing-extensions" }, - { name = "ua-parser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/15/e0a1b586e354ea6b8d6612717bf4372aaaa6753444d5d006caf0bb116466/camoufox-0.4.11.tar.gz", hash = "sha256:0a2c9d24ac5070c104e7c2b125c0a3937f70efa416084ef88afe94c32a72eebe", size = 64409, upload-time = "2025-01-29T09:33:20.019Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/7b/a2f099a5afb9660271b3f20f6056ba679e7ab4eba42682266a65d5730f7e/camoufox-0.4.11-py3-none-any.whl", hash = "sha256:83864d434d159a7566990aa6524429a8d1a859cbf84d2f64ef4a9f29e7d2e5ff", size = 71628, upload-time = "2025-01-29T09:33:18.558Z" }, -] - [[package]] name = "certifi" version = "2025.11.12" @@ -390,18 +219,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -411,15 +228,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "cssselect" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870, upload-time = "2025-03-10T09:30:29.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786, upload-time = "2025-03-10T09:30:28.048Z" }, -] - [[package]] name = "curl-cffi" version = "0.13.0" @@ -441,20 +249,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, ] -[[package]] -name = "cython" -version = "3.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/17/55fc687ba986f2210298fa2f60fec265fa3004c3f9a1e958ea1fe2d4e061/cython-3.2.2.tar.gz", hash = "sha256:c3add3d483acc73129a61d105389344d792c17e7c1cee24863f16416bd071634", size = 3275797, upload-time = "2025-11-30T12:48:20.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/ba/d785f60564a43bddbb7316134252a55d67ff6f164f0be90c4bf31482da82/cython-3.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d140c2701cbb8cf960300cf1b67f3b4fa9d294d32e51b85f329bff56936a82fd", size = 2951181, upload-time = "2025-11-30T12:48:39.723Z" }, - { url = "https://files.pythonhosted.org/packages/57/0f/6fd78dc581373722bb9dedfc90c35b59ba88af988756315af227a877c7a2/cython-3.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:692a41c8fe06fb2dc55ca2c8d71c80c469fd16fe69486ed99f3b3cbb2d3af83f", size = 2968037, upload-time = "2025-11-30T12:48:47.279Z" }, - { url = "https://files.pythonhosted.org/packages/a2/4f/b5355918962ec28b376eb8e357c718d58baf32d6df7017be8d147dd4ba29/cython-3.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa24cd0bdab27ca099b2467806c684404add597c1108e07ddf7b6471653c85d7", size = 2958578, upload-time = "2025-11-30T12:48:55.354Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f2/cd60f639f0fde38b71319d7b6808e1ff17a6fd7f3feaff475b866a5c0aef/cython-3.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:177faf4d61e9f2d4d2db61194ac9ec16d3fe3041c1b6830f871a01935319eeb3", size = 2969023, upload-time = "2025-11-30T12:49:02.734Z" }, - { url = "https://files.pythonhosted.org/packages/f4/69/5430879d35235ec3d5ffd778862173b6419390509ae4e37a72bdd45d9e86/cython-3.2.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a6387e3ad31342443916db9a419509935fddd8d4cbac34aab9c895ae55326a56", size = 2874031, upload-time = "2025-11-30T12:49:18.34Z" }, - { url = "https://files.pythonhosted.org/packages/76/f2/98fd8d0b514622a789fd2824b59bd6041b799aaeeba14a8d92d52f6654dd/cython-3.2.2-py3-none-any.whl", hash = "sha256:13b99ecb9482aff6a6c12d1ca6feef6940c507af909914b49f568de74fa965fb", size = 1255106, upload-time = "2025-11-30T12:48:18.454Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -473,172 +267,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - -[[package]] -name = "geoip2" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "maxminddb" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/70/3d9e87289f79713aaf0fea9df4aa8e68776640fe59beb6299bb214610cfd/geoip2-5.2.0.tar.gz", hash = "sha256:6c9ded1953f8eb16043ed0a8ea20e6e9524ea7b65eb745724e12490aca44ef00", size = 176498, upload-time = "2025-11-20T18:21:08.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/d2/d55df737199a52b9d06e742ed2a608c525f0677e40375951372e65714fbd/geoip2-5.2.0-py3-none-any.whl", hash = "sha256:3d1546fd4eb7cad20445d027d2d9e81d3a71c074e019383f30db5d45e2c23320", size = 28991, upload-time = "2025-11-20T18:21:07.178Z" }, -] - -[[package]] -name = "greenlet" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, - { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, - { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, - { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, - { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, - { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, - { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, - { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, - { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, - { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, - { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, - { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, - { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, - { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, - { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, - { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, - { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, - { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, - { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, - { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -703,353 +331,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "language-tags" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/7e/b6a0efe4fee11e9742c1baaedf7c574084238a70b03c1d8eb2761383848f/language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6", size = 207901, upload-time = "2023-01-11T18:38:07.893Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/42/327554649ed2dd5ce59d3f5da176c7be20f9352c7c6c51597293660b7b08/language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", size = 213449, upload-time = "2023-01-11T18:38:05.692Z" }, -] - -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, -] - -[[package]] -name = "maxminddb" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/6e/6adbb0b2280a853e8b3344737fea5167e8a2a2ff67168555589b7278e2e8/maxminddb-3.0.0.tar.gz", hash = "sha256:9792b19625945dff146e2e3187f9e470b82330a912f7cea5581b8bd5af30da8b", size = 199784, upload-time = "2025-10-15T20:50:07.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/15/a3c156c609b59342799fd1c0e60e4a6f6e096e2a18107d88b61a39c76ab5/maxminddb-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8e34a0cd9a67f446a6b425b857ac7b63254b5ce64d0b38e013d0e531588b7a66", size = 53857, upload-time = "2025-10-15T20:48:38.447Z" }, - { url = "https://files.pythonhosted.org/packages/fa/42/8e367bfa2a9c8ffbe0f0b60a904d35b7cb9d6a0d3a3c8fd803a932b3226e/maxminddb-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4e1791a30ac50ec2bdd2203276bcb9da25ff1115fd6f152548b5110993297d14", size = 36118, upload-time = "2025-10-15T20:48:39.551Z" }, - { url = "https://files.pythonhosted.org/packages/47/83/367902eb1e01955d491251812af2226a7d2b2fa3893c1099b5690119ac44/maxminddb-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:722d4e5c5735f5ae2dcb0ccf972e890c41bccd15476fafe8c2a991f72c4a28d2", size = 36044, upload-time = "2025-10-15T20:48:40.927Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3b/ae5ead6809bc26bd61bab0548d08983a7b2955159df092305eaa45909f6f/maxminddb-3.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c71ec72fdbec8be4d1e9b53294d59f04c7ae73ede6273efce7516995bcb5468", size = 99409, upload-time = "2025-10-15T20:48:42.775Z" }, - { url = "https://files.pythonhosted.org/packages/84/0e/3f4bd90ba3def4b490594866641585b93e3deeef4a09aacde921e223629a/maxminddb-3.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f62b55d15f796d14a58c84a853a8ae6d2728546f5c81a15a61aa45082afc21c8", size = 97114, upload-time = "2025-10-15T20:48:44.378Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/49dea88a0931b5764e582e628a55ac2d5a64a8d97ece1853bf751cb43fd3/maxminddb-3.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78ab0b386ab51ea21d54ca42dee379dfd976d5e650ae0435848278b2aaf9d0fb", size = 96833, upload-time = "2025-10-15T20:48:45.916Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ec/4419c88774ae183bdfb386882796c85675767339eddc41d86ec3df68f61f/maxminddb-3.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9167c80dac8a524af24af22b2093c819cb2ea11c2abe986b6b29be7fa7f6c88f", size = 95065, upload-time = "2025-10-15T20:48:47.096Z" }, - { url = "https://files.pythonhosted.org/packages/56/40/31fcbe0052848292d8ea17bc3779a8a5f83fd090338a83aeb8c71057ad97/maxminddb-3.0.0-cp311-cp311-win32.whl", hash = "sha256:deb2e6bc068db799eac025ab9d1cbf96cd9fbf636a3414a79518e05fe57ae5a3", size = 35341, upload-time = "2025-10-15T20:48:48.232Z" }, - { url = "https://files.pythonhosted.org/packages/26/53/48e939bc13be41337aa23c30a3b45122e610a8fa8419af44265ff309dfa2/maxminddb-3.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:87062aec30c57af1a6e1c007391f20f1af836a459486801f169af44bc244c9e7", size = 37132, upload-time = "2025-10-15T20:48:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f0/df860ee2b8db38332416ceab8756a36d5b5ed82d3f1cccc8978cc716548a/maxminddb-3.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:612497302bf77d7c90586fc3a19b941e0c78b47f92df035e80550f044a849c96", size = 34258, upload-time = "2025-10-15T20:48:50.404Z" }, - { url = "https://files.pythonhosted.org/packages/31/df/dec231686a814f9e279afb39f3e27091770d970964bb94e7bfc1fdf01428/maxminddb-3.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bcf83c60a44ec5dfab9e5d3a0c2347ee429d31fa89f88aa283d8551fd5e2c37a", size = 54352, upload-time = "2025-10-15T20:48:51.488Z" }, - { url = "https://files.pythonhosted.org/packages/14/e3/efb6d621a8940371ecbf393f84fde01f0521116bc281c40124292a593198/maxminddb-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56856d0fadab323fb5dc3fa69bc4cb975242133cab1df2c710779738dadda75d", size = 36328, upload-time = "2025-10-15T20:48:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/51/e8/17cbe454829befb32fec83745141bb6f9ef0b593d53c4e333e938d83ed26/maxminddb-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bd05d919787719fc1026d53b0e7462cf0c389534620e407676ecf61c2d289bb", size = 36174, upload-time = "2025-10-15T20:48:53.874Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1d/5492205210570d851d5a74f5c9c01022993edc74296eb792c890318eff25/maxminddb-3.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29515dc3606d1d8fffdb4025dccf01c93d16651683e9c6d8611892a4c9f2566d", size = 101153, upload-time = "2025-10-15T20:48:55.013Z" }, - { url = "https://files.pythonhosted.org/packages/a6/07/f96b5e4fdfdd2cc7a9724f3fa40b6bc282c9d9bdcf85b1920a0dee50c00b/maxminddb-3.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5edc32894643c93279de2d889c0b98906277e7e91cbba709bc55f5500ecca", size = 99465, upload-time = "2025-10-15T20:48:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/d7/30/ef2c167277292ce360bcd2a11e0fa9fe2e4e67e7c7b49fff2eab7caae787/maxminddb-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a095ce04e404315f9d47a186a7d96b11a283430d811ba6b0530167233100b95", size = 98395, upload-time = "2025-10-15T20:48:57.489Z" }, - { url = "https://files.pythonhosted.org/packages/04/c9/71ce286a4ba12ec74b094d1a627d57a306349f4f23ce66d3ec2eca045e9f/maxminddb-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d3645a44c392d9ffdea4d2252d70b2910eee47d56b8305da0c0923a63e895d6", size = 97320, upload-time = "2025-10-15T20:48:58.7Z" }, - { url = "https://files.pythonhosted.org/packages/da/4a/3e3f24f876242dd53a8a95250669e2f08b8cd8bc4640e947c982efcdaca6/maxminddb-3.0.0-cp312-cp312-win32.whl", hash = "sha256:c0e6d54da5d85d38e674fee9b04b1ad9212c38cb57adcc7c86bb4ed71b2b6555", size = 35481, upload-time = "2025-10-15T20:49:00.755Z" }, - { url = "https://files.pythonhosted.org/packages/71/de/56feda63d5d8d896c2dcfa6ef9754a429fa2c5353fa5f0c32ed1f46fa004/maxminddb-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:4931ee0cbba030e1b729599e485aca438b668432ccd1eb73770c93bbc38f2b60", size = 37295, upload-time = "2025-10-15T20:49:01.993Z" }, - { url = "https://files.pythonhosted.org/packages/87/4a/87c86516dee431a9e6cdded7eb865b5b7fc7c73b17262a50c75e2da5c9b6/maxminddb-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:f55fb5c607dc4ddab7eba67da92d75921ef7d8e682ab47d21935566dc6990021", size = 34263, upload-time = "2025-10-15T20:49:03.237Z" }, - { url = "https://files.pythonhosted.org/packages/e6/4e/9c97eaea080d450ab63197a20ead71cc652f99f5ecb1e68fc0896db33ac8/maxminddb-3.0.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:f2162e6bee9643d86647518c891756ef5091afb1c2d522fc206d7c26187862eb", size = 37615, upload-time = "2025-10-15T20:49:04.728Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a1/900f105a1562667e22566bbbfa3ca8ca6ea2b2e7e31ff30673459809be74/maxminddb-3.0.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:eace3ccb184546287d27fce54852751e935c00f9ba7b66fece09e7761503cd13", size = 38070, upload-time = "2025-10-15T20:49:06.138Z" }, - { url = "https://files.pythonhosted.org/packages/39/6b/247ef29d080be9b57efba90e33445e2cc028f8cf09beb5e697e5132c95dd/maxminddb-3.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:babf6c600361e5f9bc3e3873b900ab044f6cdc7c0f15c086e0b52d2e005ab949", size = 35365, upload-time = "2025-10-15T20:49:07.233Z" }, - { url = "https://files.pythonhosted.org/packages/7d/dd/ca6c4929e1a634507a99e8143c81ecc3f3f913e1a0c47b656b5a006538ec/maxminddb-3.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:024221e821f3385dd41f13e2d0ac5afca569b69a6d7755c8c960edaf31c0e47f", size = 35919, upload-time = "2025-10-15T20:49:08.3Z" }, - { url = "https://files.pythonhosted.org/packages/e6/41/a7faaf244114d47994fef85accd06dd906832cdcc5465ad27b48e0f11f2d/maxminddb-3.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bc838d9060e6e623b4bd055d498a2159a072d43beb3eeaefde5af39ac1b1b249", size = 54354, upload-time = "2025-10-15T20:49:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c8/76b3c0ea1f180209496cb401892a4ad197ee23ac1f370da578fffa466418/maxminddb-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:29fd164067b2765752d5970aaef823a51d262a484c59e866c20dbf99f45453ac", size = 36312, upload-time = "2025-10-15T20:49:10.713Z" }, - { url = "https://files.pythonhosted.org/packages/b6/96/b2d5ab37458ec892d7d52b6a9e6aa9992354d61df20b9978bae60e35d17a/maxminddb-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6008ecba67b7024b80e3f28e276b736f2f984795cd4a6922ddffaba8038d6a60", size = 36174, upload-time = "2025-10-15T20:49:12.232Z" }, - { url = "https://files.pythonhosted.org/packages/ec/3d/c22a117c1c6ca42a62be9473f12d113e2eab72ac28c032a290d0fbbd488e/maxminddb-3.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:684fec138b463d1fc6fa88fd2967e25b3af0629eb0b5e6f3bbc017e64e2f68c6", size = 101205, upload-time = "2025-10-15T20:49:13.342Z" }, - { url = "https://files.pythonhosted.org/packages/df/e6/a170e6ae3492d8e334a6ce9e39668f2b8d0cb0a158804460b5d851315230/maxminddb-3.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2dd94deb1baa19f9fd17968b90b7c03589078a3000972948e3aecfa723300d1", size = 99495, upload-time = "2025-10-15T20:49:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7b/8b/a18aba0838a85bf9ff30f165d3cb5f52967858e89e54aa8a7509a674f253/maxminddb-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:880e233c00a4403bb6dd5e406f156be3c6a5a5b37b472102928014ab21c12b4b", size = 98402, upload-time = "2025-10-15T20:49:15.864Z" }, - { url = "https://files.pythonhosted.org/packages/2e/24/85d15613b6dbf2b683a5b9817640c3baac1931edf59a7465c54e0ad92084/maxminddb-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8031353eaece26ee634bcba2cba3bb91092f52a69e5f5dbc5931d59f84b2de", size = 97303, upload-time = "2025-10-15T20:49:17.121Z" }, - { url = "https://files.pythonhosted.org/packages/1f/fb/23883b82abf92d0613375a9a9f4a4412ff8cc0596d124070832bf7f783a6/maxminddb-3.0.0-cp313-cp313-win32.whl", hash = "sha256:2d325fcdbf1ba356ac47304ba3fc8605b21b9bd09d0818b24f43ebecc71c5e29", size = 35481, upload-time = "2025-10-15T20:49:18.266Z" }, - { url = "https://files.pythonhosted.org/packages/4b/19/a498bf14a86e98475d4ca994988e8f072dccfd407d026403ad95725321de/maxminddb-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ad60671645bf88b853f126999cafd0e61ad668f210176ea24a8b5e99dd3e049", size = 37299, upload-time = "2025-10-15T20:49:19.488Z" }, - { url = "https://files.pythonhosted.org/packages/34/ca/36fecfbed3ef0175b569b07f968fb56d591a6effdaeda81f1247dc8034a4/maxminddb-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:4ca8989b0e389404f268c8650aeeadda34417f87baa405325b051a511f56c382", size = 34254, upload-time = "2025-10-15T20:49:20.533Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/eed553828b6bcd1bb8c3eb74818aee471c63aba6612128d73c20da122921/maxminddb-3.0.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1a89feae4b7296f24a76467788dad73578bbf51e4cf9672e61ef1be1320dd3d6", size = 37412, upload-time = "2025-10-15T20:49:21.649Z" }, - { url = "https://files.pythonhosted.org/packages/65/3b/0dba6d1d078e2a0523bc0a89c0060b1366a2f9ee8f72f47c33a107e56fab/maxminddb-3.0.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e874238be93b6e6b3c6589795015edb9b935d2638806439ee65c66669e399b2d", size = 37876, upload-time = "2025-10-15T20:49:23.165Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/d4b0636aec3671c3aedf97013b24b6b310de62d6ab373b775a3a8dd594a9/maxminddb-3.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a45fc20423952f84a73c008d40ea5b1d8c343a3c58d229a45d78a20093817a6f", size = 35334, upload-time = "2025-10-15T20:49:24.554Z" }, - { url = "https://files.pythonhosted.org/packages/37/d7/6ff7c7386365f639cff20fb1d0f4b6533b12706b4a0ae05cfdaf0b41f768/maxminddb-3.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d3796460179976fea3f99855bd75811af74f5659699584d4b7e80a7c66b52893", size = 35887, upload-time = "2025-10-15T20:49:25.614Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ee/e7d8942bbdf06a2082611d52e89ece4e6064195ddb18a7158b5f53e76bc7/maxminddb-3.0.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7997f0b1ed0210b0790a1885e9bc38bed53fdcaaf37141cf8dd1a97894c8fa1b", size = 54335, upload-time = "2025-10-15T20:49:26.728Z" }, - { url = "https://files.pythonhosted.org/packages/77/e1/bd16679264463d2c340940b5b320cc97cd240a3a0b6c1811c88b82d292db/maxminddb-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:21f9d0c164f7c058f419cd9b1105f01606b2abf77b456e8d201700804667686f", size = 36528, upload-time = "2025-10-15T20:49:27.842Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/cf8a5ca1ede1fcd1b306d504a3949d52fd87124fcc2c2180afbcf714ff54/maxminddb-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e7fe7a2a6b2275736fd090d98e081146a1b81abf475d6d2dfd1b106d3792b208", size = 36147, upload-time = "2025-10-15T20:49:28.935Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e0/cd48d467c34ac108fcee9e444dd537e27f04a945d787acd5614f1127dbe5/maxminddb-3.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aaa656d5cb2ea60f4e845669be6a759a25aa1f0cd67fbfef0e64759af28dbb7", size = 101010, upload-time = "2025-10-15T20:49:30.122Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4b/323ec8abe811702bcea537a0aa5e83442f48f1974084bdb048b75424536c/maxminddb-3.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b86dedb1ae681376bcc31bec37d7d674c86ff05687738ab333f18988f17c3a", size = 99249, upload-time = "2025-10-15T20:49:31.332Z" }, - { url = "https://files.pythonhosted.org/packages/d7/1a/520fcb6ad4185857fbba74cb1ee42b580492049c3730ce0687ac54dbe731/maxminddb-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fde874c2af4dd4488c423b4f4bc2ec9931e5e992f382feb43d07dc4e8dda5e24", size = 98418, upload-time = "2025-10-15T20:49:32.879Z" }, - { url = "https://files.pythonhosted.org/packages/af/20/8650798c8e0806a07e2534c562acf1e3d735ac7aaee0abab370f80c56977/maxminddb-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397a90b6db4563abe6d5fb08e112afd4481b8354fa64d289625c2e941b787860", size = 97092, upload-time = "2025-10-15T20:49:34.529Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/0b89ba3651519c021cf2258a5d0c1af306245f2f5e3e05618f28200270d2/maxminddb-3.0.0-cp314-cp314-win32.whl", hash = "sha256:38ba5ce9efb19f1bf5915f9dca8495f5eb63677cf7760ba7038850d3dcde6572", size = 36144, upload-time = "2025-10-15T20:49:35.702Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9f/06b232bb67f13580ae36aa15ddac85d4b38203363801cdd72d70aaba1b56/maxminddb-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c8c62de3b10d1940abd0c18ed57879b6618e26b78a17ab4a576ac30f96a0e83", size = 38069, upload-time = "2025-10-15T20:49:37.141Z" }, - { url = "https://files.pythonhosted.org/packages/70/dd/a76d5b755bdbe24dd1e03f2ce1951d8d9e70fca80abc7498abbec4441f71/maxminddb-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:f0bfd8326a012cb2c8a282531ce900e6535dfc3e50d99c04e64453f782201bd0", size = 34804, upload-time = "2025-10-15T20:49:38.44Z" }, - { url = "https://files.pythonhosted.org/packages/f3/02/512f13edf16e8bf3e01cff958d58bcf023c9ab3ba3d5cda92e011a57f34e/maxminddb-3.0.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6a4330999ab1987f82d32ad969fddcace596dbf8a5c075104e88568f0c326f94", size = 58264, upload-time = "2025-10-15T20:49:39.891Z" }, - { url = "https://files.pythonhosted.org/packages/10/3a/0e8551e0a254489769ab5336bddf5898bead7f6dad17645f85473922a01d/maxminddb-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d22b9706c96872b5ee08a1085af9af986832e52c9cc399aa445f4d3d52f2475", size = 38622, upload-time = "2025-10-15T20:49:41.88Z" }, - { url = "https://files.pythonhosted.org/packages/46/85/1ee105d2870c62df68aa2c6c2b886910de8936d9a67d261e55b0dfc9be53/maxminddb-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:758f1b656c8bf7b5308d23bbcfe61918f1dd57394331e4300402fd2814e748f4", size = 38028, upload-time = "2025-10-15T20:49:43.326Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5f/b01340be810e8a846db21e839a5d80305628765803fabb45aab31d4c96f6/maxminddb-3.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1efe051b84bd90c86571ae7e02479d09dcf6f84925702c2abe870f8cec4a443c", size = 117895, upload-time = "2025-10-15T20:49:45.209Z" }, - { url = "https://files.pythonhosted.org/packages/3e/34/06c029169335d3557904749f15e4a03361471869655693c8b83d4b64dd29/maxminddb-3.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a87b1d0409aea9903a5f9f2700abb798c46a115941f28ac23b7905fc3ce3967", size = 114609, upload-time = "2025-10-15T20:49:46.59Z" }, - { url = "https://files.pythonhosted.org/packages/18/e1/3dc1c8742e552be3f074943a6fc2e27a6cdaef559613f03b5158833994a4/maxminddb-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:029fce189a447d0c0a5729685ae1af3793de364f7301391cde5604282901c52f", size = 113977, upload-time = "2025-10-15T20:49:47.817Z" }, - { url = "https://files.pythonhosted.org/packages/96/f3/566927372c444dc35f54d17a3a608939586c9b3e5ce9d1282a27ed0e1dde/maxminddb-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0652a3858c6c6f70e94df1eb3e4755087b8278c4280c473fb533280ff2a4d281", size = 111818, upload-time = "2025-10-15T20:49:49.217Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b6/6f97bbb6535cbf0e57cbfff3cde75d3b419d0e192b38d6f33050d6f97ec3/maxminddb-3.0.0-cp314-cp314t-win32.whl", hash = "sha256:b240375d51a91f98d050f3f4f1ed164c0c7e4fb7c55ef7767242ef3d56147853", size = 37395, upload-time = "2025-10-15T20:49:50.401Z" }, - { url = "https://files.pythonhosted.org/packages/96/41/5296294d494cfc111d000d2e85367a89abe5756f86d2669453c6e1a39334/maxminddb-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b23e77eaa343dccdf85aad422ac16b65ed2181abdc7678945d07e89187a0b15b", size = 39553, upload-time = "2025-10-15T20:49:51.464Z" }, - { url = "https://files.pythonhosted.org/packages/8c/34/7910eff964987e82ffa78b94a59e943e52cd2e51f40a1ffede0e9e6ecf86/maxminddb-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d2306c0a50eeafc882fcfce0c99b44400ae95b9283187902236248c2cf904d2a", size = 35418, upload-time = "2025-10-15T20:49:52.528Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d9/fe6243736aed22a7fa877e4bce7f0a5d45e07115fc0da2b5c261d2d8dc19/maxminddb-3.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:aca2e80a32749e0484e8f0e885014efe0c43218acb6f44c6ca4cd8daa90c8f98", size = 34992, upload-time = "2025-10-15T20:50:00.902Z" }, - { url = "https://files.pythonhosted.org/packages/70/a6/1719d8e980edd735f2ad10e47fe0345a4598cb20658b53b8649e7adecec3/maxminddb-3.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0ea42502167190cc52e11ec9c26ff642ee276418f557304d1773b888563feb3f", size = 34643, upload-time = "2025-10-15T20:50:01.985Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/fef9105639a0abedf2891b2492399417f4e2a166a1bad4f7aa6d262254b9/maxminddb-3.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71db302b3eafab7d421d482fcaa977bf94d696dde4db6aab9a9ffd3f4ea3c186", size = 39163, upload-time = "2025-10-15T20:50:03.074Z" }, - { url = "https://files.pythonhosted.org/packages/e6/88/dd27d7834a5db56e7ac6dca93fd5c4c5f2abd3be3eab0dae2c07023dcb20/maxminddb-3.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98eb4bd49301a9066c6ed5817867b8289af3dc41f60a59e29ee2460d80e348da", size = 37988, upload-time = "2025-10-15T20:50:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/02/c5/fa52785a18300a0a5dfa36d4d640bbaf6dc7f41db102f86293156101bdd0/maxminddb-3.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2de7f8255157107e3ed03690d9dc0c278fd5da108b2f36b8b161e21c67175940", size = 37219, upload-time = "2025-10-15T20:50:05.667Z" }, -] - -[[package]] -name = "msgspec" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/59/fdcb3af72f750a8de2bcf39d62ada70b5eb17b06d7f63860e0a679cb656b/msgspec-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:09e0efbf1ac641fedb1d5496c59507c2f0dc62a052189ee62c763e0aae217520", size = 193345, upload-time = "2025-11-24T03:55:20.613Z" }, - { url = "https://files.pythonhosted.org/packages/5a/15/3c225610da9f02505d37d69a77f4a2e7daae2a125f99d638df211ba84e59/msgspec-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23ee3787142e48f5ee746b2909ce1b76e2949fbe0f97f9f6e70879f06c218b54", size = 186867, upload-time = "2025-11-24T03:55:22.4Z" }, - { url = "https://files.pythonhosted.org/packages/81/36/13ab0c547e283bf172f45491edfdea0e2cecb26ae61e3a7b1ae6058b326d/msgspec-0.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81f4ac6f0363407ac0465eff5c7d4d18f26870e00674f8fcb336d898a1e36854", size = 215351, upload-time = "2025-11-24T03:55:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/6b/96/5c095b940de3aa6b43a71ec76275ac3537b21bd45c7499b5a17a429110fa/msgspec-0.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb4d873f24ae18cd1334f4e37a178ed46c9d186437733351267e0a269bdf7e53", size = 219896, upload-time = "2025-11-24T03:55:25.356Z" }, - { url = "https://files.pythonhosted.org/packages/98/7a/81a7b5f01af300761087b114dafa20fb97aed7184d33aab64d48874eb187/msgspec-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b92b8334427b8393b520c24ff53b70f326f79acf5f74adb94fd361bcff8a1d4e", size = 220389, upload-time = "2025-11-24T03:55:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/70/c0/3d0cce27db9a9912421273d49eab79ce01ecd2fed1a2f1b74af9b445f33c/msgspec-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:562c44b047c05cc0384e006fae7a5e715740215c799429e0d7e3e5adf324285a", size = 223348, upload-time = "2025-11-24T03:55:28.311Z" }, - { url = "https://files.pythonhosted.org/packages/89/5e/406b7d578926b68790e390d83a1165a9bfc2d95612a1a9c1c4d5c72ea815/msgspec-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:d1dcc93a3ce3d3195985bfff18a48274d0b5ffbc96fa1c5b89da6f0d9af81b29", size = 188713, upload-time = "2025-11-24T03:55:29.553Z" }, - { url = "https://files.pythonhosted.org/packages/47/87/14fe2316624ceedf76a9e94d714d194cbcb699720b210ff189f89ca4efd7/msgspec-0.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:aa387aa330d2e4bd69995f66ea8fdc87099ddeedf6fdb232993c6a67711e7520", size = 174229, upload-time = "2025-11-24T03:55:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/d9/6f/1e25eee957e58e3afb2a44b94fa95e06cebc4c236193ed0de3012fff1e19/msgspec-0.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2aba22e2e302e9231e85edc24f27ba1f524d43c223ef5765bd8624c7df9ec0a5", size = 196391, upload-time = "2025-11-24T03:55:32.677Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ee/af51d090ada641d4b264992a486435ba3ef5b5634bc27e6eb002f71cef7d/msgspec-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:716284f898ab2547fedd72a93bb940375de9fbfe77538f05779632dc34afdfde", size = 188644, upload-time = "2025-11-24T03:55:33.934Z" }, - { url = "https://files.pythonhosted.org/packages/49/d6/9709ee093b7742362c2934bfb1bbe791a1e09bed3ea5d8a18ce552fbfd73/msgspec-0.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:558ed73315efa51b1538fa8f1d3b22c8c5ff6d9a2a62eff87d25829b94fc5054", size = 218852, upload-time = "2025-11-24T03:55:35.575Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" }, - { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d1/b902d38b6e5ba3bdddbec469bba388d647f960aeed7b5b3623a8debe8a76/msgspec-0.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c1ff8db03be7598b50dd4b4a478d6fe93faae3bd54f4f17aa004d0e46c14c46", size = 196463, upload-time = "2025-11-24T03:55:43.405Z" }, - { url = "https://files.pythonhosted.org/packages/57/b6/eff0305961a1d9447ec2b02f8c73c8946f22564d302a504185b730c9a761/msgspec-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f6532369ece217fd37c5ebcfd7e981f2615628c21121b7b2df9d3adcf2fd69b8", size = 188650, upload-time = "2025-11-24T03:55:44.761Z" }, - { url = "https://files.pythonhosted.org/packages/99/93/f2ec1ae1de51d3fdee998a1ede6b2c089453a2ee82b5c1b361ed9095064a/msgspec-0.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a1697da2f85a751ac3cc6a97fceb8e937fc670947183fb2268edaf4016d1ee", size = 218834, upload-time = "2025-11-24T03:55:46.441Z" }, - { url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" }, - { url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" }, - { url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" }, - { url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" }, - { url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" }, - { url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" }, - { url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" }, - { url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" }, - { url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" }, - { url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" }, - { url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" }, - { url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" }, - { url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" }, - { url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" }, - { url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" }, - { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, - { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, - { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, - { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, - { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, - { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, - { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, - { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, - { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, - { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, - { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, - { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, - { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, - { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, - { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, - { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, - { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, - { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, - { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, - { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, - { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, - { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, - { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, - { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, - { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, - { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, - { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, - { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, - { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, - { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, - { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, - { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, - { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, - { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, - { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, - { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, - { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, - { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, - { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, - { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, - { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, - { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, - { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, -] - [[package]] name = "ndjson" version = "0.3.1" @@ -1084,155 +365,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/b7/bc0cdbc2cc3a66fcac82c79912e135a0110b37b790a14c477f18e18d90cd/nodejs_wheel_binaries-24.11.1-py2.py3-none-win_arm64.whl", hash = "sha256:376b9ea1c4bc1207878975dfeb604f7aa5668c260c6154dcd2af9d42f7734116", size = 39026497, upload-time = "2025-11-18T18:21:54.634Z" }, ] -[[package]] -name = "numpy" -version = "2.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641, upload-time = "2025-11-16T22:49:19.336Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324, upload-time = "2025-11-16T22:49:22.582Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872, upload-time = "2025-11-16T22:49:25.408Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148, upload-time = "2025-11-16T22:49:27.549Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282, upload-time = "2025-11-16T22:49:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903, upload-time = "2025-11-16T22:49:34.191Z" }, - { url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672, upload-time = "2025-11-16T22:49:37.2Z" }, - { url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896, upload-time = "2025-11-16T22:49:39.727Z" }, - { url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608, upload-time = "2025-11-16T22:49:42.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442, upload-time = "2025-11-16T22:49:43.99Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555, upload-time = "2025-11-16T22:49:47.092Z" }, - { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" }, - { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" }, - { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" }, - { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" }, - { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" }, - { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" }, - { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" }, - { url = "https://files.pythonhosted.org/packages/db/69/9cde09f36da4b5a505341180a3f2e6fadc352fd4d2b7096ce9778db83f1a/numpy-2.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0f23b44f57077c1ede8c5f26b30f706498b4862d3ff0a7298b8411dd2f043ff", size = 16728251, upload-time = "2025-11-16T22:50:19.013Z" }, - { url = "https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188", size = 12254652, upload-time = "2025-11-16T22:50:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/78/da/8c7738060ca9c31b30e9301ee0cf6c5ffdbf889d9593285a1cead337f9a5/numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccc933afd4d20aad3c00bcef049cb40049f7f196e0397f1109dba6fed63267b0", size = 5083172, upload-time = "2025-11-16T22:50:24.562Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b4/ee5bb2537fb9430fd2ef30a616c3672b991a4129bb1c7dcc42aa0abbe5d7/numpy-2.3.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afaffc4393205524af9dfa400fa250143a6c3bc646c08c9f5e25a9f4b4d6a903", size = 6622990, upload-time = "2025-11-16T22:50:26.47Z" }, - { url = "https://files.pythonhosted.org/packages/95/03/dc0723a013c7d7c19de5ef29e932c3081df1c14ba582b8b86b5de9db7f0f/numpy-2.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c75442b2209b8470d6d5d8b1c25714270686f14c749028d2199c54e29f20b4d", size = 14248902, upload-time = "2025-11-16T22:50:28.861Z" }, - { url = "https://files.pythonhosted.org/packages/f5/10/ca162f45a102738958dcec8023062dad0cbc17d1ab99d68c4e4a6c45fb2b/numpy-2.3.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e06aa0af8c0f05104d56450d6093ee639e15f24ecf62d417329d06e522e017", size = 16597430, upload-time = "2025-11-16T22:50:31.56Z" }, - { url = "https://files.pythonhosted.org/packages/2a/51/c1e29be863588db58175175f057286900b4b3327a1351e706d5e0f8dd679/numpy-2.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ed89927b86296067b4f81f108a2271d8926467a8868e554eaf370fc27fa3ccaf", size = 16024551, upload-time = "2025-11-16T22:50:34.242Z" }, - { url = "https://files.pythonhosted.org/packages/83/68/8236589d4dbb87253d28259d04d9b814ec0ecce7cb1c7fed29729f4c3a78/numpy-2.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51c55fe3451421f3a6ef9a9c1439e82101c57a2c9eab9feb196a62b1a10b58ce", size = 18533275, upload-time = "2025-11-16T22:50:37.651Z" }, - { url = "https://files.pythonhosted.org/packages/40/56/2932d75b6f13465239e3b7b7e511be27f1b8161ca2510854f0b6e521c395/numpy-2.3.5-cp313-cp313-win32.whl", hash = "sha256:1978155dd49972084bd6ef388d66ab70f0c323ddee6f693d539376498720fb7e", size = 6277637, upload-time = "2025-11-16T22:50:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/0c/88/e2eaa6cffb115b85ed7c7c87775cb8bcf0816816bc98ca8dbfa2ee33fe6e/numpy-2.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:00dc4e846108a382c5869e77c6ed514394bdeb3403461d25a829711041217d5b", size = 12779090, upload-time = "2025-11-16T22:50:42.503Z" }, - { url = "https://files.pythonhosted.org/packages/8f/88/3f41e13a44ebd4034ee17baa384acac29ba6a4fcc2aca95f6f08ca0447d1/numpy-2.3.5-cp313-cp313-win_arm64.whl", hash = "sha256:0472f11f6ec23a74a906a00b48a4dcf3849209696dff7c189714511268d103ae", size = 10194710, upload-time = "2025-11-16T22:50:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/13/cb/71744144e13389d577f867f745b7df2d8489463654a918eea2eeb166dfc9/numpy-2.3.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:414802f3b97f3c1eef41e530aaba3b3c1620649871d8cb38c6eaff034c2e16bd", size = 16827292, upload-time = "2025-11-16T22:50:47.715Z" }, - { url = "https://files.pythonhosted.org/packages/71/80/ba9dc6f2a4398e7f42b708a7fdc841bb638d353be255655498edbf9a15a8/numpy-2.3.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5ee6609ac3604fa7780e30a03e5e241a7956f8e2fcfe547d51e3afa5247ac47f", size = 12378897, upload-time = "2025-11-16T22:50:51.327Z" }, - { url = "https://files.pythonhosted.org/packages/2e/6d/db2151b9f64264bcceccd51741aa39b50150de9b602d98ecfe7e0c4bff39/numpy-2.3.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:86d835afea1eaa143012a2d7a3f45a3adce2d7adc8b4961f0b362214d800846a", size = 5207391, upload-time = "2025-11-16T22:50:54.542Z" }, - { url = "https://files.pythonhosted.org/packages/80/ae/429bacace5ccad48a14c4ae5332f6aa8ab9f69524193511d60ccdfdc65fa/numpy-2.3.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:30bc11310e8153ca664b14c5f1b73e94bd0503681fcf136a163de856f3a50139", size = 6721275, upload-time = "2025-11-16T22:50:56.794Z" }, - { url = "https://files.pythonhosted.org/packages/74/5b/1919abf32d8722646a38cd527bc3771eb229a32724ee6ba340ead9b92249/numpy-2.3.5-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1062fde1dcf469571705945b0f221b73928f34a20c904ffb45db101907c3454e", size = 14306855, upload-time = "2025-11-16T22:50:59.208Z" }, - { url = "https://files.pythonhosted.org/packages/a5/87/6831980559434973bebc30cd9c1f21e541a0f2b0c280d43d3afd909b66d0/numpy-2.3.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce581db493ea1a96c0556360ede6607496e8bf9b3a8efa66e06477267bc831e9", size = 16657359, upload-time = "2025-11-16T22:51:01.991Z" }, - { url = "https://files.pythonhosted.org/packages/dd/91/c797f544491ee99fd00495f12ebb7802c440c1915811d72ac5b4479a3356/numpy-2.3.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:cc8920d2ec5fa99875b670bb86ddeb21e295cb07aa331810d9e486e0b969d946", size = 16093374, upload-time = "2025-11-16T22:51:05.291Z" }, - { url = "https://files.pythonhosted.org/packages/74/a6/54da03253afcbe7a72785ec4da9c69fb7a17710141ff9ac5fcb2e32dbe64/numpy-2.3.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9ee2197ef8c4f0dfe405d835f3b6a14f5fee7782b5de51ba06fb65fc9b36e9f1", size = 18594587, upload-time = "2025-11-16T22:51:08.585Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/aff53abbdd41b0ecca94285f325aff42357c6b5abc482a3fcb4994290b18/numpy-2.3.5-cp313-cp313t-win32.whl", hash = "sha256:70b37199913c1bd300ff6e2693316c6f869c7ee16378faf10e4f5e3275b299c3", size = 6405940, upload-time = "2025-11-16T22:51:11.541Z" }, - { url = "https://files.pythonhosted.org/packages/d5/81/50613fec9d4de5480de18d4f8ef59ad7e344d497edbef3cfd80f24f98461/numpy-2.3.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b501b5fa195cc9e24fe102f21ec0a44dffc231d2af79950b451e0d99cea02234", size = 12920341, upload-time = "2025-11-16T22:51:14.312Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ab/08fd63b9a74303947f34f0bd7c5903b9c5532c2d287bead5bdf4c556c486/numpy-2.3.5-cp313-cp313t-win_arm64.whl", hash = "sha256:a80afd79f45f3c4a7d341f13acbe058d1ca8ac017c165d3fa0d3de6bc1a079d7", size = 10262507, upload-time = "2025-11-16T22:51:16.846Z" }, - { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" }, - { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" }, - { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" }, - { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" }, - { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" }, - { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" }, - { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" }, - { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" }, - { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689, upload-time = "2025-11-16T22:52:23.247Z" }, - { url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053, upload-time = "2025-11-16T22:52:26.367Z" }, - { url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635, upload-time = "2025-11-16T22:52:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770, upload-time = "2025-11-16T22:52:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768, upload-time = "2025-11-16T22:52:33.593Z" }, - { url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263, upload-time = "2025-11-16T22:52:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213, upload-time = "2025-11-16T22:52:39.38Z" }, -] - -[[package]] -name = "orjson" -version = "3.11.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/68/6b3659daec3a81aed5ab47700adb1a577c76a5452d35b91c88efee89987f/orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8", size = 245318, upload-time = "2025-12-06T15:54:02.355Z" }, - { url = "https://files.pythonhosted.org/packages/e9/00/92db122261425f61803ccf0830699ea5567439d966cbc35856fe711bfe6b/orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc", size = 129491, upload-time = "2025-12-06T15:54:03.877Z" }, - { url = "https://files.pythonhosted.org/packages/94/4f/ffdcb18356518809d944e1e1f77589845c278a1ebbb5a8297dfefcc4b4cb/orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968", size = 132167, upload-time = "2025-12-06T15:54:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/0a8caff96f4503f4f7dd44e40e90f4d14acf80d3b7a97cb88747bb712d3e/orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7", size = 130516, upload-time = "2025-12-06T15:54:06.274Z" }, - { url = "https://files.pythonhosted.org/packages/4d/63/43d4dc9bd9954bff7052f700fdb501067f6fb134a003ddcea2a0bb3854ed/orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd", size = 135695, upload-time = "2025-12-06T15:54:07.702Z" }, - { url = "https://files.pythonhosted.org/packages/87/6f/27e2e76d110919cb7fcb72b26166ee676480a701bcf8fc53ac5d0edce32f/orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9", size = 139664, upload-time = "2025-12-06T15:54:08.828Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/5966153a5f1be49b5fbb8ca619a529fde7bc71aa0a376f2bb83fed248bcd/orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef", size = 137289, upload-time = "2025-12-06T15:54:09.898Z" }, - { url = "https://files.pythonhosted.org/packages/a7/34/8acb12ff0299385c8bbcbb19fbe40030f23f15a6de57a9c587ebf71483fb/orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9", size = 138784, upload-time = "2025-12-06T15:54:11.022Z" }, - { url = "https://files.pythonhosted.org/packages/ee/27/910421ea6e34a527f73d8f4ee7bdffa48357ff79c7b8d6eb6f7b82dd1176/orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125", size = 141322, upload-time = "2025-12-06T15:54:12.427Z" }, - { url = "https://files.pythonhosted.org/packages/87/a3/4b703edd1a05555d4bb1753d6ce44e1a05b7a6d7c164d5b332c795c63d70/orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814", size = 413612, upload-time = "2025-12-06T15:54:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/1b/36/034177f11d7eeea16d3d2c42a1883b0373978e08bc9dad387f5074c786d8/orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5", size = 150993, upload-time = "2025-12-06T15:54:15.189Z" }, - { url = "https://files.pythonhosted.org/packages/44/2f/ea8b24ee046a50a7d141c0227c4496b1180b215e728e3b640684f0ea448d/orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880", size = 141774, upload-time = "2025-12-06T15:54:16.451Z" }, - { url = "https://files.pythonhosted.org/packages/8a/12/cc440554bf8200eb23348a5744a575a342497b65261cd65ef3b28332510a/orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d", size = 135109, upload-time = "2025-12-06T15:54:17.73Z" }, - { url = "https://files.pythonhosted.org/packages/a3/83/e0c5aa06ba73a6760134b169f11fb970caa1525fa4461f94d76e692299d9/orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1", size = 133193, upload-time = "2025-12-06T15:54:19.426Z" }, - { url = "https://files.pythonhosted.org/packages/cb/35/5b77eaebc60d735e832c5b1a20b155667645d123f09d471db0a78280fb49/orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c", size = 126830, upload-time = "2025-12-06T15:54:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" }, - { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" }, - { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" }, - { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" }, - { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" }, - { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" }, - { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" }, - { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" }, - { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" }, - { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" }, - { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" }, - { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" }, - { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" }, - { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" }, - { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" }, - { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" }, - { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" }, - { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" }, - { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" }, - { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" }, - { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" }, - { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" }, - { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" }, - { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" }, - { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" }, - { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" }, - { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" }, - { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1242,25 +374,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] -[[package]] -name = "patchright" -version = "1.56.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/d6/ac03353f1b3138541356a044c2d7d3e01b73f9555c7281dab3423c5d44da/patchright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9df1e0b2c35a298ad2807044699476d3e872f76117c319077b68d7b8ac8bffae", size = 40595069, upload-time = "2025-11-11T18:59:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f2/e33cd3e6916ed959f5b9be1182363e19771be81541a77b2c5615a6434cc8/patchright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d8e86d743f4da99f83e9962f9317bd3aaabe4e9db86d3e12cb1d0f39e2f469", size = 39383869, upload-time = "2025-11-11T18:59:23.829Z" }, - { url = "https://files.pythonhosted.org/packages/5f/49/510b8fb761a7e6a2cbd8381f2254fd9442055e27961c0565944d5634ccb6/patchright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:e939a9651ebad6ed5afb84e6cf8b874fd80e4b03a912509dc5159f0fa2d4a752", size = 40595069, upload-time = "2025-11-11T18:59:26.9Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ad/17f72b906a1d3d9750b26964d4481340defbfc02f039c888756061f41a33/patchright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:cef0f31334dece118fa8dbe20e0c0ac97ea539a2a3e7dd007a67d05b4c3e0512", size = 46246766, upload-time = "2025-11-11T18:59:29.985Z" }, - { url = "https://files.pythonhosted.org/packages/53/b4/6546fb868f464c888cee8e95d5b0cc4e276afc5e531bc3ca737e2a910f77/patchright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02329457d4f9e92b49209adceec33097042b6a6f03de31185a2f99721aeded6a", size = 46094291, upload-time = "2025-11-11T18:59:32.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/4b/a43f86570cb72f732723c19ba71f1c02200ed1cc762d71daa2c35718a529/patchright-1.56.0-py3-none-win32.whl", hash = "sha256:5502899cd8764ccc7a18c3cb47a76e49b25ec4eae155ec5ebea9e5e0022cc811", size = 35623487, upload-time = "2025-11-11T18:59:36.156Z" }, - { url = "https://files.pythonhosted.org/packages/95/8d/37cc3127c6085ffa6482832a07f84587f2f04dbbaf194cc395bb2afa811c/patchright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:0fe85d8f6b541689185115f3ce69e87a7f5dd964ec6aaeac635d4db5a906aec1", size = 35623491, upload-time = "2025-11-11T18:59:38.853Z" }, - { url = "https://files.pythonhosted.org/packages/c5/37/0b79ddfc6a2ba16e9f258670ea40a74c791d925f1847cf4ad0e739bc7506/patchright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:8bbf1c753e7b07b889a3ff43109ce3cea457605ea6b0477e65af4d9a2c5585cf", size = 31232602, upload-time = "2025-11-11T18:59:41.537Z" }, -] - [[package]] name = "platformdirs" version = "4.5.1" @@ -1270,25 +383,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] -[[package]] -name = "playwright" -version = "1.56.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet" }, - { name = "pyee" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/31/a5362cee43f844509f1f10d8a27c9cc0e2f7bdce5353d304d93b2151c1b1/playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c", size = 40611424, upload-time = "2025-11-11T18:39:10.175Z" }, - { url = "https://files.pythonhosted.org/packages/ef/95/347eef596d8778fb53590dc326c344d427fa19ba3d42b646fce2a4572eb3/playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b", size = 39400228, upload-time = "2025-11-11T18:39:13.915Z" }, - { url = "https://files.pythonhosted.org/packages/b9/54/6ad97b08b2ca1dfcb4fbde4536c4f45c0d9d8b1857a2d20e7bbfdf43bf15/playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65", size = 40611424, upload-time = "2025-11-11T18:39:17.093Z" }, - { url = "https://files.pythonhosted.org/packages/e4/76/6d409e37e82cdd5dda3df1ab958130ae32b46e42458bd4fc93d7eb8749cb/playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c", size = 46263122, upload-time = "2025-11-11T18:39:20.619Z" }, - { url = "https://files.pythonhosted.org/packages/4f/84/fb292cc5d45f3252e255ea39066cd1d2385c61c6c1596548dfbf59c88605/playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34", size = 46110645, upload-time = "2025-11-11T18:39:24.005Z" }, - { url = "https://files.pythonhosted.org/packages/61/bd/8c02c3388ae14edc374ac9f22cbe4e14826c6a51b2d8eaf86e89fabee264/playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721", size = 35639837, upload-time = "2025-11-11T18:39:27.174Z" }, - { url = "https://files.pythonhosted.org/packages/64/27/f13b538fbc6b7a00152f4379054a49f6abc0bf55ac86f677ae54bc49fb82/playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94", size = 35639843, upload-time = "2025-11-11T18:39:30.851Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c7/3ee8b556107995846576b4fe42a08ed49b8677619421f2afacf6ee421138/playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f", size = 31248959, upload-time = "2025-11-11T18:39:33.998Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -1314,105 +408,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" }, ] -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -1534,18 +529,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pyee" -version = "13.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -1555,46 +538,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pyobjc-core" -version = "12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, - { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, - { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, -] - -[[package]] -name = "pyobjc-framework-cocoa" -version = "12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyobjc-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, - { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, - { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, -] - -[[package]] -name = "pysocks" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, -] - [[package]] name = "pytest" version = "9.0.2" @@ -1693,18 +636,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "requests-file" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, -] - [[package]] name = "ruff" version = "0.14.8" @@ -1743,8 +674,6 @@ dependencies = [ { name = "ndjson" }, { name = "pydantic" }, { name = "requests" }, - { name = "scrapling", extra = ["fetchers"] }, - { name = "types-requests" }, ] [package.dev-dependencies] @@ -1768,8 +697,6 @@ requires-dist = [ { name = "ndjson", specifier = ">=0.3.1" }, { name = "pydantic", specifier = ">=2.11.10" }, { name = "requests", specifier = ">=2.32.5" }, - { name = "scrapling", extras = ["fetchers"], specifier = ">=0.3.5" }, - { name = "types-requests", specifier = ">=2.32.4.20250913" }, ] [package.metadata.requires-dev] @@ -1784,45 +711,6 @@ dev = [ { name = "types-requests", specifier = ">=2.32.4.20250913" }, ] -[[package]] -name = "scrapling" -version = "0.3.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cssselect" }, - { name = "lxml" }, - { name = "orjson" }, - { name = "tldextract" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/ad/dfe0efce82fcba28a7bec36d26ab3eac99d7440eb85b01ab98c5bfc7c1d7/scrapling-0.3.11.tar.gz", hash = "sha256:e3c16b03e02ec302bc451eab943146968cfc8622c317a2edf3ba839dd5ac89c9", size = 98939, upload-time = "2025-12-03T01:53:25.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/7f/085b53835f0516360fc0f9f26d829daa4d2e2c55375fa691e226025e5464/scrapling-0.3.11-py3-none-any.whl", hash = "sha256:0cf4b64e3a804e3ea7f21ec328de33175dbdc485fee48840fdbac634862b093b", size = 106953, upload-time = "2025-12-03T01:53:24.292Z" }, -] - -[package.optional-dependencies] -fetchers = [ - { name = "camoufox" }, - { name = "click" }, - { name = "curl-cffi" }, - { name = "geoip2" }, - { name = "msgspec" }, - { name = "patchright" }, - { name = "playwright" }, -] - -[[package]] -name = "screeninfo" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cython", marker = "sys_platform == 'darwin'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/bb/e69e5e628d43f118e0af4fc063c20058faa8635c95a1296764acc8167e27/screeninfo-0.8.1.tar.gz", hash = "sha256:9983076bcc7e34402a1a9e4d7dabf3729411fd2abb3f3b4be7eba73519cd2ed1", size = 10666, upload-time = "2022-09-09T11:35:23.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/bf/c5205d480307bef660e56544b9e3d7ff687da776abb30c9cb3f330887570/screeninfo-0.8.1-py3-none-any.whl", hash = "sha256:e97d6b173856edcfa3bd282f81deb528188aff14b11ec3e195584e7641be733c", size = 12907, upload-time = "2022-09-09T11:35:21.351Z" }, -] - [[package]] name = "soupsieve" version = "2.8" @@ -1832,33 +720,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, ] -[[package]] -name = "tldextract" -version = "5.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "idna" }, - { name = "requests" }, - { name = "requests-file" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/78/182641ea38e3cfd56e9c7b3c0d48a53d432eea755003aa544af96403d4ac/tldextract-5.3.0.tar.gz", hash = "sha256:b3d2b70a1594a0ecfa6967d57251527d58e00bb5a91a74387baa0d87a0678609", size = 128502, upload-time = "2025-04-22T06:19:37.491Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/7c/ea488ef48f2f544566947ced88541bc45fae9e0e422b2edbf165ee07da99/tldextract-5.3.0-py3-none-any.whl", hash = "sha256:f70f31d10b55c83993f55e91ecb7c5d84532a8972f22ec578ecfbe5ea2292db2", size = 107384, upload-time = "2025-04-22T06:19:36.304Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, -] - [[package]] name = "ty" version = "0.0.1a32" @@ -1950,26 +811,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "ua-parser" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ua-parser-builtins" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/70/0e/ed98be735bc89d5040e0c60f5620d0b8c04e9e7da99ed1459e8050e90a77/ua_parser-1.0.1.tar.gz", hash = "sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d", size = 728106, upload-time = "2025-02-01T14:13:32.508Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl", hash = "sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea", size = 31410, upload-time = "2025-02-01T14:13:28.458Z" }, -] - -[[package]] -name = "ua-parser-builtins" -version = "0.18.0.post1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/d3/13adff37f15489c784cc7669c35a6c3bf94b87540229eedf52ef2a1d0175/ua_parser_builtins-0.18.0.post1-py3-none-any.whl", hash = "sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d", size = 86077, upload-time = "2024-12-05T18:44:36.732Z" }, -] - [[package]] name = "urllib3" version = "2.6.0" @@ -1992,113 +833,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846 wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] - -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, -] From 2148d9bd0700464aef997cc46470ad5492f00651 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 13:49:42 -0500 Subject: [PATCH 012/114] feat(nix): add health --- lua/cp/health.lua | 12 +++++++++--- lua/cp/utils.lua | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lua/cp/health.lua b/lua/cp/health.lua index 6a3913f..d3a3fea 100644 --- a/lua/cp/health.lua +++ b/lua/cp/health.lua @@ -19,11 +19,13 @@ local function check() end if utils.is_nix_build() then - vim.health.ok('Nix-built Python environment detected') + local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install' + vim.health.ok('Nix Python environment detected (' .. source .. ')') local py = utils.get_nix_python() + vim.health.info('Python: ' .. py) local r = vim.system({ py, '--version' }, { text = true }):wait() if r.code == 0 then - vim.health.info('Python: ' .. r.stdout:gsub('\n', '')) + vim.health.info('Python version: ' .. r.stdout:gsub('\n', '')) end else if vim.fn.executable('uv') == 1 then @@ -36,6 +38,10 @@ local function check() vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)') end + if vim.fn.executable('nix') == 1 then + vim.health.info('nix available but Python environment not resolved via nix') + end + local plugin_path = utils.get_plugin_path() local venv_dir = plugin_path .. '/.venv' if vim.fn.isdirectory(venv_dir) == 1 then @@ -52,7 +58,7 @@ local function check() vim.health.error('GNU time not found: ' .. (time_cap.reason or '')) end - local timeout_cap = utils.time_capability() + local timeout_cap = utils.timeout_capability() if timeout_cap.ok then vim.health.ok('GNU timeout found: ' .. timeout_cap.path) else diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 0ac4916..0031216 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -3,6 +3,7 @@ local M = {} local logger = require('cp.log') local _nix_python = nil +local _nix_discovered = false local uname = vim.loop.os_uname() @@ -89,6 +90,10 @@ function M.get_nix_python() return _nix_python end +function M.is_nix_discovered() + return _nix_discovered +end + function M.get_python_cmd(module, plugin_path) if _nix_python then return { _nix_python, '-m', 'scrapers.' .. module } @@ -113,6 +118,7 @@ local function discover_nix_python() end local plugin_path = M.get_plugin_path() + logger.log('Building Python environment with nix...', nil, true) local result = vim .system( { 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' }, @@ -141,6 +147,7 @@ local function discover_nix_python() end _nix_python = python_path + _nix_discovered = true return true end From 0f9715298eb52adc27bedc3bed0b1cf1a311389c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 14:02:13 -0500 Subject: [PATCH 013/114] fix(ci): remove deprecated setups --- lua/cp/utils.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 0031216..39e2876 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -82,18 +82,24 @@ function M.get_plugin_path() return vim.fn.fnamemodify(plugin_path, ':h:h:h') end +---@return boolean function M.is_nix_build() return _nix_python ~= nil end +---@return string|nil function M.get_nix_python() return _nix_python end +---@return boolean function M.is_nix_discovered() return _nix_discovered end +---@param module string +---@param plugin_path string +---@return string[] function M.get_python_cmd(module, plugin_path) if _nix_python then return { _nix_python, '-m', 'scrapers.' .. module } @@ -103,6 +109,7 @@ end local python_env_setup = false +---@return boolean local function discover_nix_python() local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim' local cache_file = cache_dir .. '/nix-python' From e02a29bd4029a38a26d5892c00a6b5dc4b0bc115 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 14:04:36 -0500 Subject: [PATCH 014/114] fix(ci): remove duplicate workflows --- .github/workflows/ci.yaml | 112 ------------------------------------ .github/workflows/test.yaml | 4 +- 2 files changed, 1 insertion(+), 115 deletions(-) delete mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index bf6bcea..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,112 +0,0 @@ -name: ci -on: - workflow_call: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - changes: - runs-on: ubuntu-latest - outputs: - lua: ${{ steps.changes.outputs.lua }} - python: ${{ steps.changes.outputs.python }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: changes - with: - filters: | - lua: - - 'lua/**' - - 'spec/**' - - 'plugin/**' - - 'after/**' - - 'ftdetect/**' - - '*.lua' - - '.luarc.json' - - 'stylua.toml' - - 'selene.toml' - python: - - 'scripts/**' - - 'scrapers/**' - - 'tests/**' - - 'pyproject.toml' - - 'uv.lock' - - lua-format: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . - - lua-lint: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . - - lua-typecheck: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: mrcjkb/lua-typecheck-action@v0 - with: - checklevel: Warning - directories: lua - configpath: .luarc.json - - python-format: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.python == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - - run: uv tool install ruff - - run: ruff format --check . - - python-lint: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.python == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - - run: uv tool install ruff - - run: ruff check . - - python-typecheck: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.python == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - - run: uv sync --dev - - run: uvx ty check . - - python-test: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.python == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - - run: uv sync --dev - - run: uv run camoufox fetch - - run: uv run pytest tests/ -v diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ed7be0c..34bd84f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -44,9 +44,7 @@ jobs: - uses: actions/checkout@v4 - name: Install uv uses: astral-sh/setup-uv@v4 - - name: Install dependencies with pytest + - name: Install dependencies run: uv sync --dev - - name: Fetch camoufox data - run: uv run camoufox fetch - name: Run Python tests run: uv run pytest tests/ -v From b6f3398bbce99e6cc9f1ff277236bca937f3df4d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 14:07:53 -0500 Subject: [PATCH 015/114] fix(ci): formatting and typing --- lua/cp/scraper.lua | 7 ++++++- scrapers/codeforces.py | 2 +- tests/test_scrapers.py | 24 +++++++++--------------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 4771cc1..10fc7b7 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -44,7 +44,12 @@ local function run_scraper(platform, subcommand, args, opts) local handle handle = uv.spawn( cmd[1], - { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env, cwd = plugin_path }, + { + args = vim.list_slice(cmd, 2), + stdio = { nil, stdout, stderr }, + env = env, + cwd = plugin_path, + }, function(code, signal) if buf ~= '' and opts.on_event then local ok_tail, ev_tail = pcall(vim.json.decode, buf) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 1d01b8d..a67489f 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -78,7 +78,7 @@ def _extract_title(block: Tag) -> tuple[str, str]: def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]: st = block.find("div", class_="sample-test") - if not st: + if not isinstance(st, Tag): return [], False input_pres: list[Tag] = [ diff --git a/tests/test_scrapers.py b/tests/test_scrapers.py index 75f3cb0..8ce468f 100644 --- a/tests/test_scrapers.py +++ b/tests/test_scrapers.py @@ -6,11 +6,6 @@ from scrapers.models import ( TestsResult, ) -MODEL_FOR_MODE = { - "metadata": MetadataResult, - "contests": ContestListResult, -} - MATRIX = { "cses": { "metadata": ("introductory_problems",), @@ -43,17 +38,16 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode): assert rc in (0, 1), f"Bad exit code {rc}" assert objs, f"No JSON output for {scraper}:{mode}" - if mode in ("metadata", "contests"): - Model = MODEL_FOR_MODE[mode] - model = Model.model_validate(objs[-1]) - assert model is not None + if mode == "metadata": + model = MetadataResult.model_validate(objs[-1]) assert model.success is True - if mode == "metadata": - assert model.url - assert len(model.problems) >= 1 - assert all(isinstance(p.id, str) and p.id for p in model.problems) - else: - assert len(model.contests) >= 1 + assert model.url + assert len(model.problems) >= 1 + assert all(isinstance(p.id, str) and p.id for p in model.problems) + elif mode == "contests": + model = ContestListResult.model_validate(objs[-1]) + assert model.success is True + assert len(model.contests) >= 1 else: assert len(objs) >= 1, "No test objects returned" validated_any = False From c192afc5d7d8ae4b852471432be02390b955d874 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 14:09:18 -0500 Subject: [PATCH 016/114] fix(ci): format --- lua/cp/scraper.lua | 52 +++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 10fc7b7..d64a765 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -42,36 +42,32 @@ local function run_scraper(platform, subcommand, args, opts) local buf = '' local handle - handle = uv.spawn( - cmd[1], - { - args = vim.list_slice(cmd, 2), - stdio = { nil, stdout, stderr }, - env = env, - cwd = plugin_path, - }, - function(code, signal) - if buf ~= '' and opts.on_event then - local ok_tail, ev_tail = pcall(vim.json.decode, buf) - if ok_tail then - opts.on_event(ev_tail) - end - buf = '' - end - if opts.on_exit then - opts.on_exit({ success = (code == 0), code = code, signal = signal }) - end - if not stdout:is_closing() then - stdout:close() - end - if not stderr:is_closing() then - stderr:close() - end - if handle and not handle:is_closing() then - handle:close() + handle = uv.spawn(cmd[1], { + args = vim.list_slice(cmd, 2), + stdio = { nil, stdout, stderr }, + env = env, + cwd = plugin_path, + }, function(code, signal) + if buf ~= '' and opts.on_event then + local ok_tail, ev_tail = pcall(vim.json.decode, buf) + if ok_tail then + opts.on_event(ev_tail) end + buf = '' end - ) + if opts.on_exit then + opts.on_exit({ success = (code == 0), code = code, signal = signal }) + end + if not stdout:is_closing() then + stdout:close() + end + if not stderr:is_closing() then + stderr:close() + end + if handle and not handle:is_closing() then + handle:close() + end + end) if not handle then logger.log('Failed to start scraper process', vim.log.levels.ERROR) From 6045042dfb6c39a63549167705e6909dd062b42d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 17:24:26 -0500 Subject: [PATCH 017/114] fix: surface runtime check failures as clean notifications Problem: when required dependencies (GNU time/timeout, Python env) are missing, config.setup() throws a raw error() that surfaces as a Lua traceback. On macOS without coreutils the message is also redundant ("GNU time not found: GNU time not found") and offers no install hint. Solution: wrap config.setup() in pcall inside ensure_initialized(), strip the Lua source-location prefix, and emit a vim.notify at ERROR level. Add Darwin-specific install guidance to the GNU time/timeout not-found messages. Pass capability reasons directly instead of wrapping them in a redundant outer message. --- lua/cp/init.lua | 16 ++++++++++++---- lua/cp/utils.lua | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 3cb36c1..088272a 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -15,17 +15,25 @@ local initialized = false local function ensure_initialized() if initialized then - return + return true end local user_config = vim.g.cp or {} - local config = config_module.setup(user_config) - config_module.set_current_config(config) + local ok, result = pcall(config_module.setup, user_config) + if not ok then + local msg = tostring(result):gsub('^.+:%d+: ', '') + vim.notify(msg, vim.log.levels.ERROR) + return false + end + config_module.set_current_config(result) initialized = true + return true end ---@return nil function M.handle_command(opts) - ensure_initialized() + if not ensure_initialized() then + return + end local commands = require('cp.commands') commands.handle_command(opts) end diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 39e2876..8539f99 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -60,7 +60,11 @@ local function find_gnu_time() _time_cached = true _time_path = nil - _time_reason = 'GNU time not found' + if uname and uname.sysname == 'Darwin' then + _time_reason = 'GNU time not found (install via: brew install coreutils)' + else + _time_reason = 'GNU time not found' + end return _time_path, _time_reason end @@ -251,12 +255,12 @@ function M.check_required_runtime() local time = M.time_capability() if not time.ok then - return false, 'GNU time not found: ' .. (time.reason or '') + return false, time.reason end local timeout = M.timeout_capability() if not timeout.ok then - return false, 'GNU timeout not found: ' .. (timeout.reason or '') + return false, timeout.reason end if not M.setup_python_env() then @@ -310,7 +314,11 @@ local function find_gnu_timeout() _timeout_cached = true _timeout_path = nil - _timeout_reason = 'GNU timeout not found' + if uname and uname.sysname == 'Darwin' then + _timeout_reason = 'GNU timeout not found (install via: brew install coreutils)' + else + _timeout_reason = 'GNU timeout not found' + end return _timeout_path, _timeout_reason end From 06f72bbe2b7150506c8a85cfb0e83bc166a429ce Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 17:27:01 -0500 Subject: [PATCH 018/114] fix: only show user-configured platforms in picker Problem: tbl_deep_extend merges user platforms on top of defaults, so all four default platforms survive even when the user only configures a subset. The picker then shows platforms the user never intended to use. Solution: before the deep merge, prune any default platform not present in the user's platforms table. This preserves per-platform default filling (the user doesn't have to re-specify every field) while ensuring only explicitly configured platforms appear. --- lua/cp/config.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index dec8878..4ed762b 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -292,7 +292,15 @@ end ---@return cp.Config function M.setup(user_config) vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } }) - local cfg = vim.tbl_deep_extend('force', vim.deepcopy(M.defaults), user_config or {}) + local defaults = vim.deepcopy(M.defaults) + if user_config and user_config.platforms then + for plat in pairs(defaults.platforms) do + if not user_config.platforms[plat] then + defaults.platforms[plat] = nil + end + end + end + local cfg = vim.tbl_deep_extend('force', defaults, user_config or {}) if not next(cfg.languages) then error('[cp.nvim] At least one language must be configured') From 976838d981cb2b0779474671cacc94d40879bedb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 17:30:16 -0500 Subject: [PATCH 019/114] fix: always run uv sync to recover from partial installs Problem: setup_python_env() skips uv sync when .venv/ exists. If a previous sync was interrupted (e.g. network timeout), the directory exists but is broken, and every subsequent session silently uses a corrupt environment. Solution: remove the isdirectory guard and always run uv sync. It is idempotent and near-instant when dependencies are already installed, so the only cost is one subprocess call per session. --- lua/cp/utils.lua | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 8539f99..fd4cf71 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -175,22 +175,17 @@ function M.setup_python_env() if vim.fn.executable('uv') == 1 then local plugin_path = M.get_plugin_path() - local venv_dir = plugin_path .. '/.venv' - if vim.fn.isdirectory(venv_dir) == 0 then - logger.log('Setting up Python environment for scrapers...') - local env = vim.fn.environ() - env.VIRTUAL_ENV = '' - env.PYTHONPATH = '' - env.CONDA_PREFIX = '' - local result = vim - .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) - :wait() - if result.code ~= 0 then - logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) - return false - end - logger.log('Python environment setup complete.') + local env = vim.fn.environ() + env.VIRTUAL_ENV = '' + env.PYTHONPATH = '' + env.CONDA_PREFIX = '' + local result = vim + .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) + :wait() + if result.code ~= 0 then + logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) + return false end python_env_setup = true From 622620f6d087fd442a7885943ea0ba18a22e89ed Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 17:33:44 -0500 Subject: [PATCH 020/114] feat: add debug logging to python env, scraper, and runner Problem: with debug = true, there is not enough diagnostic output to troubleshoot environment or execution issues. The resolved python path, scraper commands, and compile/run shell commands are not logged. Solution: add logger.log calls at key decision points: python env resolution (nix vs uv vs discovery), uv sync stderr output, scraper subprocess commands, and compile/run shell strings. All gated behind the existing debug flag so they only appear when debug = true. --- lua/cp/runner/execute.lua | 2 ++ lua/cp/scraper.lua | 2 ++ lua/cp/utils.lua | 6 ++++++ 3 files changed, 10 insertions(+) diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 76d055a..9e40b4f 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -43,6 +43,7 @@ end function M.compile(compile_cmd, substitutions, on_complete) local cmd = substitute_template(compile_cmd, substitutions) local sh = table.concat(cmd, ' ') .. ' 2>&1' + logger.log('compile: ' .. sh) local t0 = vim.uv.hrtime() vim.system({ 'sh', '-c', sh }, { text = false }, function(r) @@ -119,6 +120,7 @@ function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete) local sec = math.ceil(timeout_ms / 1000) local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec) local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog) + logger.log('run: ' .. sh) local t0 = vim.uv.hrtime() vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index d64a765..d2613cf 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -30,6 +30,8 @@ local function run_scraper(platform, subcommand, args, opts) vim.list_extend(cmd, { subcommand }) vim.list_extend(cmd, args) + logger.log('scraper cmd: ' .. table.concat(cmd, ' ')) + local env = vim.fn.environ() env.VIRTUAL_ENV = '' env.PYTHONPATH = '' diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index fd4cf71..05a715d 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -169,12 +169,14 @@ function M.setup_python_env() end if _nix_python then + logger.log('Python env: nix (python=' .. _nix_python .. ')') python_env_setup = true return true end if vim.fn.executable('uv') == 1 then local plugin_path = M.get_plugin_path() + logger.log('Python env: uv sync (dir=' .. plugin_path .. ')') local env = vim.fn.environ() env.VIRTUAL_ENV = '' @@ -187,12 +189,16 @@ function M.setup_python_env() logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) return false end + if result.stderr and result.stderr ~= '' then + logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', '')) + end python_env_setup = true return true end if vim.fn.executable('nix') == 1 then + logger.log('Python env: nix discovery') if discover_nix_python() then python_env_setup = true return true From 49e4233b3f7b289c30fa5bd69e454184c9735a8b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 18 Feb 2026 17:48:06 -0500 Subject: [PATCH 021/114] fix: decouple python env setup from config init Problem: setup_python_env() is called from check_required_runtime() during config.setup(), which runs on the very first :CP command. The uv sync and nix build calls use vim.system():wait(), blocking the Neovim event loop. During the block the UI is frozen and vim.schedule-based log messages never render, so the user sees an unresponsive editor with no feedback. Solution: remove setup_python_env() from check_required_runtime() so config init is instant. Call it lazily from run_scraper() instead, only when a scraper subprocess is actually needed. Use vim.notify + vim.cmd.redraw() before blocking calls so the notification renders immediately via a forced screen repaint, rather than being queued behind vim.schedule. --- lua/cp/scraper.lua | 9 +++++++++ lua/cp/utils.lua | 9 ++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index d2613cf..21cd697 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -25,6 +25,15 @@ end ---@param args string[] ---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) } local function run_scraper(platform, subcommand, args, opts) + if not utils.setup_python_env() then + local msg = 'no Python environment available (install uv or nix)' + logger.log(msg, vim.log.levels.ERROR) + if opts and opts.on_exit then + opts.on_exit({ success = false, error = msg }) + end + return { success = false, error = msg } + end + local plugin_path = utils.get_plugin_path() local cmd = utils.get_python_cmd(platform, plugin_path) vim.list_extend(cmd, { subcommand }) diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 05a715d..b120a6f 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -129,7 +129,8 @@ local function discover_nix_python() end local plugin_path = M.get_plugin_path() - logger.log('Building Python environment with nix...', nil, true) + vim.notify('[cp.nvim] Building Python environment with nix...', vim.log.levels.INFO) + vim.cmd.redraw() local result = vim .system( { 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' }, @@ -177,6 +178,8 @@ function M.setup_python_env() if vim.fn.executable('uv') == 1 then local plugin_path = M.get_plugin_path() logger.log('Python env: uv sync (dir=' .. plugin_path .. ')') + vim.notify('[cp.nvim] Setting up Python environment...', vim.log.levels.INFO) + vim.cmd.redraw() local env = vim.fn.environ() env.VIRTUAL_ENV = '' @@ -264,10 +267,6 @@ function M.check_required_runtime() return false, timeout.reason end - if not M.setup_python_env() then - return false, 'no Python environment available (install uv or nix)' - end - return true end From 760e7d77313dedeb4a28c6764c325213a18d97ba Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 20 Feb 2026 17:47:36 -0500 Subject: [PATCH 022/114] fix(ci): format --- lua/cp/utils.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index b120a6f..b602940 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -189,7 +189,10 @@ function M.setup_python_env() .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) :wait() if result.code ~= 0 then - logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) + logger.log( + 'Failed to setup Python environment: ' .. (result.stderr or ''), + vim.log.levels.ERROR + ) return false end if result.stderr and result.stderr ~= '' then From ff5ba39a592d079780819bb8226dcb35741349a4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 21 Feb 2026 23:58:09 -0500 Subject: [PATCH 023/114] docs: fix dependencies section in readme Problem: time and timeout were listed as optional dependencies despite being required for plugin initialization. nix was not mentioned as an alternative to uv for the Python scraping environment. Solution: rename section to "Dependencies", list time/timeout first, and add nix as an alternative to uv for scraping. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cf82417..427496d 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,12 @@ Install using your package manager of choice or via luarocks install cp.nvim ``` -## Optional Dependencies +## Dependencies -- [uv](https://docs.astral.sh/uv/) for problem scraping - GNU [time](https://www.gnu.org/software/time/) and [timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html) +- [uv](https://docs.astral.sh/uv/) or [nix](https://nixos.org/) for problem + scraping ## Quick Start From 484a4a56d003cd3d7452904b799f8344fe392745 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Sun, 22 Feb 2026 11:55:13 -0500 Subject: [PATCH 024/114] fix(scraper): pass uv.spawn env as KEY=VALUE list Neovim/libuv spawn expects env as a list of KEY=VALUE strings. Passing the map from vim.fn.environ() can fail process startup with ENOENT, which breaks NDJSON test scraping and surfaces as 'Failed to start scraper process'.\n\nConvert env map to a deterministic list before uv.spawn in the NDJSON scraper path. Co-authored-by: Codex --- lua/cp/scraper.lua | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 21cd697..659983f 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -20,6 +20,15 @@ local function syshandle(result) return { success = true, data = data } end +local function spawn_env_list(env_map) + local out = {} + for key, value in pairs(env_map) do + out[#out + 1] = tostring(key) .. '=' .. tostring(value) + end + table.sort(out) + return out +end + ---@param platform string ---@param subcommand string ---@param args string[] @@ -56,7 +65,7 @@ local function run_scraper(platform, subcommand, args, opts) handle = uv.spawn(cmd[1], { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, - env = env, + env = spawn_env_list(env), cwd = plugin_path, }, function(code, signal) if buf ~= '' and opts.on_event then From 9fc34cb6fd7893bd7678777bf2b65ac25ab0e010 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Sun, 22 Feb 2026 12:01:37 -0500 Subject: [PATCH 025/114] chore(scraper): add LuaCATS types for env helper Add LuaCATS annotations to the env conversion helper and drop the table.sort call since ordering is not required by uv.spawn. Co-authored-by: Codex --- lua/cp/scraper.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 659983f..73b6177 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -20,12 +20,13 @@ local function syshandle(result) return { success = true, data = data } end +---@param env_map table +---@return string[] local function spawn_env_list(env_map) local out = {} for key, value in pairs(env_map) do out[#out + 1] = tostring(key) .. '=' .. tostring(value) end - table.sort(out) return out end From db5bd791f964a919e672d0b54bf06c6286648e89 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 22 Feb 2026 15:48:13 -0500 Subject: [PATCH 026/114] fix(cache): invalidate stale cache on version mismatch Problem: after an install or update, the on-disk cache may contain data written by an older version of the plugin whose format no longer matches what the current code expects. Solution: embed a CACHE_VERSION in every saved cache file. On load, if the stored version is missing or differs from the current one, wipe the cache and rewrite it. Corrupt (non-decodable) cache files are handled the same way instead of only logging an error. --- lua/cp/cache.lua | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 544aaf4..f3d0a94 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -38,6 +38,8 @@ local M = {} +local CACHE_VERSION = 1 + local logger = require('cp.log') local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_data = {} @@ -65,9 +67,15 @@ function M.load() local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) if ok then - cache_data = decoded + if decoded._version ~= CACHE_VERSION then + cache_data = {} + M.save() + else + cache_data = decoded + end else - logger.log('Could not decode json in cache file', vim.log.levels.ERROR) + cache_data = {} + M.save() end loaded = true end @@ -78,6 +86,7 @@ function M.save() vim.schedule(function() vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') + cache_data._version = CACHE_VERSION local encoded = vim.json.encode(cache_data) local lines = vim.split(encoded, '\n') vim.fn.writefile(lines, cache_file) From d3ac300ea05f6e0c8ae9f9a40db5b79b90b27ea1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 22 Feb 2026 15:54:04 -0500 Subject: [PATCH 027/114] fix(cache): remove unused logger import Problem: the logger import became unused after replacing the error log with a silent cache wipe. Solution: drop the require. --- lua/cp/cache.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index f3d0a94..efdcad7 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -40,7 +40,6 @@ local M = {} local CACHE_VERSION = 1 -local logger = require('cp.log') local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_data = {} local loaded = false From 1c31abe3d6ad53e9dfeb4ae64090429547e515cb Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Sun, 22 Feb 2026 20:44:40 -0500 Subject: [PATCH 028/114] fix(open_url): open problem URL when switching problems Also fix contest-change detection so URL open logic triggers when either platform or contest changes. This makes :CP next/:CP prev and problem jumps open the correct page when open_url is enabled. Co-authored-by: Codex --- doc/cp.nvim.txt | 2 +- lua/cp/setup.lua | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index e85ea45..49cdaf7 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -143,7 +143,7 @@ run CSES problems with Rust using the single schema: (default: concatenates contest_id and problem_id, lowercased) {ui} (|CpUI|) UI settings: panel, diff backend, picker. {open_url} (boolean) Open the contest & problem url in the browser - when the contest is first opened. + when a new contest is opened or the active problem changes. *CpPlatform* Fields: ~ diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index e3bb38d..deeadd9 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -121,6 +121,7 @@ end ---@param language? string function M.setup_contest(platform, contest_id, problem_id, language) local old_platform, old_contest_id = state.get_platform(), state.get_contest_id() + local old_problem_id = state.get_problem_id() state.set_platform(platform) state.set_contest_id(contest_id) @@ -133,7 +134,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) end end - local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id + local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id cache.load() @@ -143,7 +144,9 @@ function M.setup_contest(platform, contest_id, problem_id, language) M.setup_problem(pid, language) start_tests(platform, contest_id, problems) - if config_module.get_config().open_url and is_new_contest and contest_data.url then + local is_new_problem = old_problem_id ~= pid + local should_open_url = config_module.get_config().open_url and (is_new_contest or is_new_problem) + if should_open_url and contest_data.url then vim.ui.open(contest_data.url:format(pid)) end end From e989897c7767b8d9c9a1be4ca5c0618b13a18ea4 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Sun, 22 Feb 2026 22:16:48 -0500 Subject: [PATCH 029/114] style(lua): format URL-open condition in setup Co-authored-by: Codex --- lua/cp/setup.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index deeadd9..5130822 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -145,7 +145,8 @@ function M.setup_contest(platform, contest_id, problem_id, language) start_tests(platform, contest_id, problems) local is_new_problem = old_problem_id ~= pid - local should_open_url = config_module.get_config().open_url and (is_new_contest or is_new_problem) + local should_open_url = config_module.get_config().open_url + and (is_new_contest or is_new_problem) if should_open_url and contest_data.url then vim.ui.open(contest_data.url:format(pid)) end From 591f70a2370d08c2fff756eaad030831d6778b59 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 23 Feb 2026 17:37:47 -0500 Subject: [PATCH 030/114] build(flake): add lua-language-server to devShell Problem: lua-language-server is not available in the dev shell, making it impossible to run local type-checking diagnostics. Solution: add lua-language-server to the devShell packages. --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 705747f..7e1551b 100644 --- a/flake.nix +++ b/flake.nix @@ -65,6 +65,7 @@ packages = with (pkgsFor system); [ uv python312 + lua-language-server ]; }; }); From 4d58db8520ad8810426950fcf381c2fb8fc57bb7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 23 Feb 2026 18:04:17 -0500 Subject: [PATCH 031/114] ci: nix config migration --- .github/workflows/quality.yaml | 29 +++++++---------------------- .luarc.json | 2 +- selene.toml | 3 +++ vim.toml | 30 ------------------------------ vim.yaml | 24 ++++++++++++++++++++++++ 5 files changed, 35 insertions(+), 53 deletions(-) delete mode 100644 vim.toml create mode 100644 vim.yaml diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index 731e74b..664b33e 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -28,6 +28,7 @@ jobs: - '*.lua' - '.luarc.json' - '*.toml' + - 'vim.yaml' python: - 'scripts/**/.py' - 'scrapers/**/*.py' @@ -45,11 +46,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . lua-lint: name: Lua Lint Check @@ -58,11 +56,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - name: Lint with Selene - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . lua-typecheck: name: Lua Type Check @@ -127,15 +122,5 @@ jobs: if: ${{ needs.changes.outputs.markdown == 'true' }} steps: - uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Install prettier - run: pnpm add -g prettier@3.1.0 - - name: Check markdown formatting with prettier - run: prettier --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.luarc.json b/.luarc.json index 3ccfeda..19558f6 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,5 +1,5 @@ { - "runtime.version": "Lua 5.1", + "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], diff --git a/selene.toml b/selene.toml index 96cf5ab..f2ada4b 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,4 @@ std = 'vim' + +[lints] +bad_string_escape = 'allow' diff --git a/vim.toml b/vim.toml deleted file mode 100644 index 8bf26ea..0000000 --- a/vim.toml +++ /dev/null @@ -1,30 +0,0 @@ -[selene] -base = "lua51" -name = "vim" - -[vim] -any = true - -[jit] -any = true - -[assert] -any = true - -[describe] -any = true - -[it] -any = true - -[before_each] -any = true - -[after_each] -any = true - -[spy] -any = true - -[stub] -any = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..401fce6 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,24 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true From 48c08825b24e1da650d8ad321a0e3318c16175b7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 23 Feb 2026 18:16:22 -0500 Subject: [PATCH 032/114] ci: add missing packages --- flake.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flake.nix b/flake.nix index 7e1551b..202b13b 100644 --- a/flake.nix +++ b/flake.nix @@ -65,6 +65,9 @@ packages = with (pkgsFor system); [ uv python312 + prettier + stylua + selene lua-language-server ]; }; From 4ccab9ee1fe375d8c9282d797809edfb7c27a9b9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 19:09:16 -0500 Subject: [PATCH 033/114] fix(config): add bit to ignored filetypes --- vim.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vim.yaml b/vim.yaml index 401fce6..3821d25 100644 --- a/vim.yaml +++ b/vim.yaml @@ -22,3 +22,5 @@ globals: any: true stub: any: true + bit: + any: true From 3cb872a65f85145e3b688c70a6f6475fbd2a9e3a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:44:04 -0500 Subject: [PATCH 034/114] fix: replace deprecated vim.loop with vim.uv Problem: vim.loop is deprecated since Neovim 0.10 in favour of vim.uv. Five call sites across scraper.lua, setup.lua, utils.lua, and health.lua still referenced the old alias. Solution: replace every vim.loop reference with vim.uv directly. --- lua/cp/health.lua | 2 +- lua/cp/scraper.lua | 2 +- lua/cp/setup.lua | 2 +- lua/cp/utils.lua | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lua/cp/health.lua b/lua/cp/health.lua index d3a3fea..97595d6 100644 --- a/lua/cp/health.lua +++ b/lua/cp/health.lua @@ -13,7 +13,7 @@ local function check() vim.health.error('cp.nvim requires Neovim 0.10.0+') end - local uname = vim.loop.os_uname() + local uname = vim.uv.os_uname() if uname.sysname == 'Windows_NT' then vim.health.error('Windows is not supported') end diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 73b6177..29cc63e 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -57,7 +57,7 @@ local function run_scraper(platform, subcommand, args, opts) env.CONDA_PREFIX = '' if opts and opts.ndjson then - local uv = vim.loop + local uv = vim.uv local stdout = uv.new_pipe(false) local stderr = uv.new_pipe(false) local buf = '' diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 5130822..0572d55 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -179,7 +179,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) contest_id = contest_id, language = lang, requested_problem_id = problem_id, - token = vim.loop.hrtime(), + token = vim.uv.hrtime(), }) logger.log('Fetching contests problems...', vim.log.levels.INFO, true) diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index b602940..285ebf8 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -5,7 +5,7 @@ local logger = require('cp.log') local _nix_python = nil local _nix_discovered = false -local uname = vim.loop.os_uname() +local uname = vim.uv.os_uname() local _time_cached = false local _time_path = nil @@ -336,7 +336,7 @@ function M.timeout_capability() end function M.cwd_executables() - local uv = vim.uv or vim.loop + local uv = vim.uv local req = uv.fs_scandir('.') if not req then return {} From d274e0c1170f6bdbe94a34f3084febc201ec6977 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:44:32 -0500 Subject: [PATCH 035/114] fix(cache): replace stale M._cache field with get_raw_cache accessor Problem: M._cache = cache_data captured the initial empty table reference at module load time. After M.load() reassigns cache_data to the decoded JSON, M._cache is permanently stale and returns the wrong table. Solution: remove the field assignment and expose get_raw_cache() which closes over cache_data and always returns the current table. --- lua/cp/cache.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index efdcad7..33342c2 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -346,6 +346,8 @@ function M.get_data_pretty() return vim.inspect(cache_data) end -M._cache = cache_data +function M.get_raw_cache() + return cache_data +end return M From 81f52738406f6c7b29bc911ec165871caaa69490 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:44:34 -0500 Subject: [PATCH 036/114] chore: convert .luarc.json to nested format and add busted library Problem: .luarc.json used the flat dotted-key format which is not the canonical LuaLS schema. The busted library was also missing, so LuaLS could not resolve types in test files. Solution: rewrite .luarc.json using nested objects and add ${3rd}/busted/library to workspace.library. --- .luarc.json | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/.luarc.json b/.luarc.json index 19558f6..e0f7a7c 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,8 +1,16 @@ { - "runtime.version": "LuaJIT", - "runtime.path": ["lua/?.lua", "lua/?/init.lua"], - "diagnostics.globals": ["vim"], - "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], - "workspace.checkThirdParty": false, - "completion.callSnippet": "Replace" + "runtime": { + "version": "LuaJIT", + "path": ["lua/?.lua", "lua/?/init.lua"] + }, + "diagnostics": { + "globals": ["vim"] + }, + "workspace": { + "library": ["$VIMRUNTIME/lua", "${3rd}/luv/library", "${3rd}/busted/library"], + "checkThirdParty": false + }, + "completion": { + "callSnippet": "Replace" + } } From 585cf2a077a813cd071179b365108a248a594a2a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:54:06 -0500 Subject: [PATCH 037/114] feat(runner): run test cases in parallel Problem: test cases were executed sequentially, each waiting for the previous process to finish before starting the next. On problems with many test cases this meant wall-clock run time scaled linearly. Solution: fan out all test case processes simultaneously. A remaining counter fires on_done once all callbacks have returned. on_each is called per completion as before; callers that pass on_each ignore its arguments so the index semantics change is non-breaking. --- lua/cp/runner/run.lua | 45 ++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 36a560c..4e4a8f6 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -276,26 +276,35 @@ function M.run_all_test_cases(indices, debug, on_each, on_done) end end - local function run_next(pos) - if pos > #to_run then - logger.log( - ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', #to_run), - vim.log.levels.INFO, - true - ) - on_done(panel_state.test_cases) - return - end - - M.run_test_case(to_run[pos], debug, function() - if on_each then - on_each(pos, #to_run) - end - run_next(pos + 1) - end) + if #to_run == 0 then + logger.log( + ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0), + vim.log.levels.INFO, + true + ) + on_done(panel_state.test_cases) + return end - run_next(1) + local total = #to_run + local remaining = total + + for _, idx in ipairs(to_run) do + M.run_test_case(idx, debug, function() + if on_each then + on_each(idx, total) + end + remaining = remaining - 1 + if remaining == 0 then + logger.log( + ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total), + vim.log.levels.INFO, + true + ) + on_done(panel_state.test_cases) + end + end) + end end ---@return PanelState From ce5648f9cfc1ef467e9126e4ab5d673b539cbdaf Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:54:27 -0500 Subject: [PATCH 038/114] docs: add statusline integration recipes Problem: cp.nvim exposed no documentation showing how to integrate its runtime state into a statusline. Users had to discover the state module API by reading source. Solution: add a STATUSLINE INTEGRATION section to the vimdoc with a state API reference and recipes for vanilla statusline, lualine, and heirline. Also anchors the *cp.State* help tag referenced in prose elsewhere in the doc. --- doc/cp.nvim.txt | 110 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 49cdaf7..27563a1 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -888,6 +888,116 @@ Functions ~ Parameters: ~ {bufnr} (integer) Buffer handle +============================================================================== +STATUSLINE INTEGRATION *cp-statusline* + +cp.nvim exposes its runtime state through a public module that can be queried +from any statusline plugin. Import it with: >lua + local state = require('cp.state') +< +All getters return nil when no problem is active, so guard every value before +use. Calling any getter outside a CP context is safe and has no side effects. + +State API ~ + *cp.State* +The following getters are available for statusline use: + + get_platform() (string?) Platform id. e.g. "codeforces", "atcoder" + get_contest_id() (string?) Contest id. e.g. "1933", "abc324" + get_problem_id() (string?) Problem id. e.g. "A", "B" + get_language() (string?) Language id. e.g. "cpp", "python" + get_base_name() (string?) Derived filename stem. e.g. "1933a" + get_source_file() (string?) Full source filename. e.g. "1933a.cc" + get_active_panel() (string?) Non-nil when the test panel is open. + +Recipe: vanilla statusline ~ + +Set vim.o.statusline from an autocommand so it is recalculated on every +BufEnter: >lua + local function cp_component() + local state = require('cp.state') + local platform = state.get_platform() + if not platform then + return '' + end + local parts = { + platform, + state.get_contest_id(), + state.get_problem_id(), + state.get_language(), + } + local filtered = {} + for _, v in ipairs(parts) do + if v then filtered[#filtered + 1] = v end + end + return '[' .. table.concat(filtered, ' · ') .. ']' + end + + vim.api.nvim_create_autocmd({ 'BufEnter', 'User' }, { + callback = function() + vim.o.statusline = cp_component() .. ' %f %=%l:%c' + end + }) +< + +Recipe: lualine ~ + +Add a custom component to any lualine section. The cond field hides the +component entirely when no problem is active: >lua + local function cp_lualine() + local state = require('cp.state') + local parts = { + state.get_platform(), + state.get_contest_id(), + state.get_problem_id(), + state.get_language(), + } + local filtered = {} + for _, v in ipairs(parts) do + if v then filtered[#filtered + 1] = v end + end + return table.concat(filtered, ' · ') + end + + require('lualine').setup({ + sections = { + lualine_c = { + { + cp_lualine, + cond = function() + return require('cp.state').get_platform() ~= nil + end, + }, + }, + }, + }) +< + +Recipe: heirline ~ + +Build a heirline component using a provider and condition: >lua + local CpComponent = { + condition = function() + return require('cp.state').get_platform() ~= nil + end, + provider = function() + local state = require('cp.state') + local parts = { + state.get_platform(), + state.get_contest_id(), + state.get_problem_id(), + state.get_language(), + } + local filtered = {} + for _, v in ipairs(parts) do + if v then filtered[#filtered + 1] = v end + end + return '[' .. table.concat(filtered, ' · ') .. ']' + end, + } +< +Include CpComponent in your heirline StatusLine spec wherever desired. + ============================================================================== PANEL KEYMAPS *cp-panel-keys* From 2c25ec616afd3c3017e0bb6e46a667ceb8c4778e Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:54:32 -0500 Subject: [PATCH 039/114] feat: add per-language template file support Problem: new solution files were always created empty, requiring users to manually paste boilerplate or rely on editor snippets that fire outside cp.nvim's control. Solution: add an optional template field to the language config. When set to a file path, its contents are written into every newly created solution buffer before the setup_code hook runs. Existing files are never overwritten. --- lua/cp/config.lua | 5 +++++ lua/cp/setup.lua | 53 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 4ed762b..6cf43d9 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -7,6 +7,7 @@ ---@class CpLanguage ---@field extension string ---@field commands CpLangCommands +---@field template? string ---@class CpPlatformOverrides ---@field extension? string @@ -215,6 +216,10 @@ local function validate_language(id, lang) commands = { lang.commands, { 'table' } }, }) + if lang.template ~= nil then + vim.validate({ template = { lang.template, 'string' } }) + end + if not lang.commands.run then error(('[cp.nvim] languages.%s.commands.run is required'):format(id)) end diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 0572d55..86fdee1 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -8,6 +8,25 @@ local logger = require('cp.log') local scraper = require('cp.scraper') local state = require('cp.state') +local function apply_template(bufnr, lang_id, platform) + local config = config_module.get_config() + local eff = config.runtime.effective[platform] + and config.runtime.effective[platform][lang_id] + if not eff or not eff.template then + return + end + local path = vim.fn.expand(eff.template) + if vim.fn.filereadable(path) ~= 1 then + logger.log( + ('[cp.nvim] template not readable: %s'):format(path), + vim.log.levels.WARN + ) + return + end + local lines = vim.fn.readfile(path) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) +end + ---Get the language of the current file from cache ---@return string? local function get_current_file_language() @@ -270,14 +289,17 @@ function M.setup_problem(problem_id, language) mods = { silent = true, noautocmd = true, keepalt = true }, }) state.set_solution_win(vim.api.nvim_get_current_win()) - if config.hooks and config.hooks.setup_code and not vim.b[prov.bufnr].cp_setup_done then - local ok = pcall(config.hooks.setup_code, state) - if ok then + if not vim.b[prov.bufnr].cp_setup_done then + apply_template(prov.bufnr, lang, platform) + if config.hooks and config.hooks.setup_code then + local ok = pcall(config.hooks.setup_code, state) + if ok then + vim.b[prov.bufnr].cp_setup_done = true + end + else + helpers.clearcol(prov.bufnr) vim.b[prov.bufnr].cp_setup_done = true end - elseif not vim.b[prov.bufnr].cp_setup_done then - helpers.clearcol(prov.bufnr) - vim.b[prov.bufnr].cp_setup_done = true end cache.set_file_state( vim.fn.fnamemodify(source_file, ':p'), @@ -300,14 +322,21 @@ function M.setup_problem(problem_id, language) local bufnr = vim.api.nvim_get_current_buf() state.set_solution_win(vim.api.nvim_get_current_win()) require('cp.ui.views').ensure_io_view() - if config.hooks and config.hooks.setup_code and not vim.b[bufnr].cp_setup_done then - local ok = pcall(config.hooks.setup_code, state) - if ok then + if not vim.b[bufnr].cp_setup_done then + local is_new = vim.api.nvim_buf_line_count(bufnr) == 1 + and vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] == '' + if is_new then + apply_template(bufnr, lang, platform) + end + if config.hooks and config.hooks.setup_code then + local ok = pcall(config.hooks.setup_code, state) + if ok then + vim.b[bufnr].cp_setup_done = true + end + else + helpers.clearcol(bufnr) vim.b[bufnr].cp_setup_done = true end - elseif not vim.b[bufnr].cp_setup_done then - helpers.clearcol(bufnr) - vim.b[bufnr].cp_setup_done = true end cache.set_file_state( vim.fn.expand('%:p'), From 84d12758c2151ad57e4b7162a260cfcb18296f1f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:57:18 -0500 Subject: [PATCH 040/114] style(setup): apply stylua formatting --- lua/cp/setup.lua | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 86fdee1..44c993d 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -10,17 +10,13 @@ local state = require('cp.state') local function apply_template(bufnr, lang_id, platform) local config = config_module.get_config() - local eff = config.runtime.effective[platform] - and config.runtime.effective[platform][lang_id] + local eff = config.runtime.effective[platform] and config.runtime.effective[platform][lang_id] if not eff or not eff.template then return end local path = vim.fn.expand(eff.template) if vim.fn.filereadable(path) ~= 1 then - logger.log( - ('[cp.nvim] template not readable: %s'):format(path), - vim.log.levels.WARN - ) + logger.log(('[cp.nvim] template not readable: %s'):format(path), vim.log.levels.WARN) return end local lines = vim.fn.readfile(path) From e685a8089f5db2d7d6ad60f85a46d8b59129b02f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 22:55:05 -0500 Subject: [PATCH 041/114] feat: add epsilon tolerance for floating-point output comparison Problem: output comparison used exact string equality after whitespace normalisation, causing correct solutions to fail on problems where floating-point answers are accepted within a tolerance (e.g. 1e-6). Solution: add an optional ui.panel.epsilon config value. When set, actual and expected output are compared token-by-token: numeric tokens are compared with math.abs(a - b) <= epsilon, non-numeric tokens fall back to exact string equality. Per-problem epsilon can also be stored in the cache and takes precedence over the global default. --- lua/cp/cache.lua | 29 ++++++++++++++++++++++++ lua/cp/config.lua | 10 ++++++++- lua/cp/runner/run.lua | 51 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 33342c2..da0f667 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -27,6 +27,7 @@ ---@field multi_test? boolean ---@field memory_mb? number ---@field timeout_ms? number +---@field epsilon? number ---@field combined_test? CombinedTest ---@field test_cases TestCase[] @@ -273,6 +274,34 @@ function M.get_constraints(platform, contest_id, problem_id) return problem_data.timeout_ms, problem_data.memory_mb end +---@param platform string +---@param contest_id string +---@param problem_id? string +---@return number? +function M.get_epsilon(platform, contest_id, problem_id) + vim.validate({ + platform = { platform, 'string' }, + contest_id = { contest_id, 'string' }, + problem_id = { problem_id, { 'string', 'nil' }, true }, + }) + + if + not cache_data[platform] + or not cache_data[platform][contest_id] + or not cache_data[platform][contest_id].index_map + then + return nil + end + + local index = cache_data[platform][contest_id].index_map[problem_id] + if not index then + return nil + end + + local problem_data = cache_data[platform][contest_id].problems[index] + return problem_data and problem_data.epsilon or nil +end + ---@param file_path string ---@return FileState|nil function M.get_file_state(file_path) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 6cf43d9..4304a31 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -21,6 +21,7 @@ ---@class PanelConfig ---@field diff_modes string[] ---@field max_output_lines integer +---@field epsilon number? ---@class DiffGitConfig ---@field args string[] @@ -174,7 +175,7 @@ M.defaults = { add_test_key = 'ga', save_and_exit_key = 'q', }, - panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50 }, + panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50, epsilon = nil }, diff = { git = { args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, @@ -368,6 +369,13 @@ function M.setup(user_config) end, 'positive integer', }, + epsilon = { + cfg.ui.panel.epsilon, + function(v) + return v == nil or (type(v) == 'number' and v >= 0) + end, + 'nil or non-negative number', + }, git = { cfg.ui.diff.git, { 'table' } }, git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' }, width = { diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 4e4a8f6..97ae7ad 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -19,6 +19,7 @@ ---@class ProblemConstraints ---@field timeout_ms number ---@field memory_mb number +---@field epsilon number? ---@class PanelState ---@field test_cases RanTestCase[] @@ -56,7 +57,8 @@ local function load_constraints_from_cache(platform, contest_id, problem_id) cache.load() local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id) if timeout_ms and memory_mb then - return { timeout_ms = timeout_ms, memory_mb = memory_mb } + local epsilon = cache.get_epsilon(platform, contest_id, problem_id) + return { timeout_ms = timeout_ms, memory_mb = memory_mb, epsilon = epsilon } end return nil end @@ -99,6 +101,49 @@ local function build_command(cmd, substitutions) return execute.build_command(cmd, substitutions) end +local function compare_outputs(actual, expected, epsilon) + local norm_actual = normalize_lines(actual) + local norm_expected = normalize_lines(expected) + + if epsilon == nil or epsilon == 0 then + return norm_actual == norm_expected + end + + local actual_lines = vim.split(norm_actual, '\n', { plain = true }) + local expected_lines = vim.split(norm_expected, '\n', { plain = true }) + + if #actual_lines ~= #expected_lines then + return false + end + + for i = 1, #actual_lines do + local a_tokens = vim.split(actual_lines[i], '%s+', { plain = false, trimempty = true }) + local e_tokens = vim.split(expected_lines[i], '%s+', { plain = false, trimempty = true }) + + if #a_tokens ~= #e_tokens then + return false + end + + for j = 1, #a_tokens do + local a_tok, e_tok = a_tokens[j], e_tokens[j] + local a_num = tonumber(a_tok) + local e_num = tonumber(e_tok) + + if a_num ~= nil and e_num ~= nil then + if math.abs(a_num - e_num) > epsilon then + return false + end + else + if a_tok ~= e_tok then + return false + end + end + end + end + + return true +end + ---@param test_case RanTestCase ---@param debug boolean? ---@param on_complete fun(result: { status: "pass"|"fail"|"tle"|"mle", actual: string, actual_highlights: Highlight[], error: string, stderr: string, time_ms: number, code: integer, ok: boolean, signal: string?, tled: boolean, mled: boolean, rss_mb: number }) @@ -143,7 +188,9 @@ local function run_single_test_case(test_case, debug, on_complete) end local expected = test_case.expected or '' - local ok = normalize_lines(out) == normalize_lines(expected) + local epsilon = (panel_state.constraints and panel_state.constraints.epsilon) + or config.ui.panel.epsilon + local ok = compare_outputs(out, expected, epsilon) local signal = r.signal if not signal and r.code and r.code >= 128 then From 24b088e8e9e71a484c0a9f36226e76471966d1e9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 23:00:38 -0500 Subject: [PATCH 042/114] style(runner): add luacats to compare_outputs --- lua/cp/runner/run.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 97ae7ad..042baf2 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -101,6 +101,10 @@ local function build_command(cmd, substitutions) return execute.build_command(cmd, substitutions) end +---@param actual string +---@param expected string +---@param epsilon number? +---@return boolean local function compare_outputs(actual, expected, epsilon) local norm_actual = normalize_lines(actual) local norm_expected = normalize_lines(expected) From d3324aafa359621945b584d38312ebd80e8761e7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 2 Mar 2026 23:44:36 -0500 Subject: [PATCH 043/114] fix(config): propagate template through platform overrides Problem: CpPlatformOverrides lacked a template field and merge_lang() never copied ov.template into the effective language config, so per-platform template overrides were silently dropped. Solution: add template? to CpPlatformOverrides and forward it in merge_lang(), matching how extension is handled. --- lua/cp/config.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 4304a31..fcd1e96 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -12,6 +12,7 @@ ---@class CpPlatformOverrides ---@field extension? string ---@field commands? CpLangCommands +---@field template? string ---@class CpPlatform ---@field enabled_languages string[] @@ -259,6 +260,9 @@ local function merge_lang(base, ov) if ov.commands then out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {}) end + if ov.template then + out.template = ov.template + end return out end From 6a395af98fab9ae11fe788d8ceaf24c3aac50b87 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 00:09:57 -0500 Subject: [PATCH 044/114] feat(config): add templates.cursor_marker for post-template cursor placement Problem: after apply_template writes a file's content to the buffer, cursor positioning was left entirely to the user's setup_code hook, forcing everyone to reimplement the same placeholder-stripping logic. Solution: add an optional templates.cursor_marker config key. When set, apply_template scans the written lines for the marker, strips it, and positions the cursor there via bufwinid so it works in both the provisional and existing-file paths. --- lua/cp/config.lua | 11 +++++++++++ lua/cp/setup.lua | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index fcd1e96..280ecd5 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -9,6 +9,9 @@ ---@field commands CpLangCommands ---@field template? string +---@class CpTemplatesConfig +---@field cursor_marker? string + ---@class CpPlatformOverrides ---@field extension? string ---@field commands? CpLangCommands @@ -86,6 +89,7 @@ ---@class cp.Config ---@field languages table ---@field platforms table +---@field templates? CpTemplatesConfig ---@field hooks Hooks ---@field debug boolean ---@field open_url boolean @@ -320,6 +324,13 @@ function M.setup(user_config) error('[cp.nvim] At least one platform must be configured') end + if cfg.templates ~= nil then + vim.validate({ templates = { cfg.templates, 'table' } }) + if cfg.templates.cursor_marker ~= nil then + vim.validate({ cursor_marker = { cfg.templates.cursor_marker, 'string' } }) + end + end + vim.validate({ hooks = { cfg.hooks, { 'table' } }, ui = { cfg.ui, { 'table' } }, diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 44c993d..43a50c0 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -21,6 +21,21 @@ local function apply_template(bufnr, lang_id, platform) end local lines = vim.fn.readfile(path) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + local marker = config.templates and config.templates.cursor_marker + if marker then + for lnum, line in ipairs(lines) do + local col = line:find(marker, 1, true) + if col then + local new_line = line:sub(1, col - 1) .. line:sub(col + #marker) + vim.api.nvim_buf_set_lines(bufnr, lnum - 1, lnum, false, { new_line }) + local winid = vim.fn.bufwinid(bufnr) + if winid ~= -1 then + vim.api.nvim_win_set_cursor(winid, { lnum, col - 1 }) + end + break + end + end + end end ---Get the language of the current file from cache From add022af8cda4292e3cee308aad48c6c964b4bb1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 00:31:42 -0500 Subject: [PATCH 045/114] refactor(hooks): replace flat hooks API with setup/on namespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the hooks API conflated distinct lifecycle scopes under a flat table with inconsistent naming (setup_code, before_run, setup_io_input), making it hard to reason about when each hook fires. Solution: introduce two namespaces — hooks.setup.{contest,code,io} for one-time initialization and hooks.on.{enter,run,debug} for recurring events. hooks.setup.contest fires once when a contest dir is newly created; hooks.on.enter is registered as a buffer-scoped BufEnter autocmd and fires immediately after setup.code. The provisional buffer setup_code callsite is removed as it ran on an unresolved temp buffer. --- lua/cp/config.lua | 66 ++++++++++++++++++++++++++++++++++----------- lua/cp/setup.lua | 52 ++++++++++++++++++++++------------- lua/cp/ui/views.lua | 23 +++++++--------- 3 files changed, 95 insertions(+), 46 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 280ecd5..7ca81eb 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -33,12 +33,23 @@ ---@class DiffConfig ---@field git DiffGitConfig +---@class CpSetupIOHooks +---@field input? fun(bufnr: integer, state: cp.State) +---@field output? fun(bufnr: integer, state: cp.State) + +---@class CpSetupHooks +---@field contest? fun(state: cp.State) +---@field code? fun(state: cp.State) +---@field io? CpSetupIOHooks + +---@class CpOnHooks +---@field enter? fun(state: cp.State) +---@field run? fun(state: cp.State) +---@field debug? fun(state: cp.State) + ---@class Hooks ----@field before_run? fun(state: cp.State) ----@field before_debug? fun(state: cp.State) ----@field setup_code? fun(state: cp.State) ----@field setup_io_input? fun(bufnr: integer, state: cp.State) ----@field setup_io_output? fun(bufnr: integer, state: cp.State) +---@field setup? CpSetupHooks +---@field on? CpOnHooks ---@class VerdictFormatData ---@field index integer @@ -156,11 +167,19 @@ M.defaults = { }, }, hooks = { - before_run = nil, - before_debug = nil, - setup_code = nil, - setup_io_input = helpers.clearcol, - setup_io_output = helpers.clearcol, + setup = { + contest = nil, + code = nil, + io = { + input = helpers.clearcol, + output = helpers.clearcol, + }, + }, + on = { + enter = nil, + run = nil, + debug = nil, + }, }, debug = false, scrapers = constants.PLATFORMS, @@ -352,12 +371,29 @@ function M.setup(user_config) end, ('one of {%s}'):format(table.concat(constants.PLATFORMS, ',')), }, - before_run = { cfg.hooks.before_run, { 'function', 'nil' }, true }, - before_debug = { cfg.hooks.before_debug, { 'function', 'nil' }, true }, - setup_code = { cfg.hooks.setup_code, { 'function', 'nil' }, true }, - setup_io_input = { cfg.hooks.setup_io_input, { 'function', 'nil' }, true }, - setup_io_output = { cfg.hooks.setup_io_output, { 'function', 'nil' }, true }, }) + if cfg.hooks.setup ~= nil then + vim.validate({ setup = { cfg.hooks.setup, 'table' } }) + vim.validate({ + contest = { cfg.hooks.setup.contest, { 'function', 'nil' }, true }, + code = { cfg.hooks.setup.code, { 'function', 'nil' }, true }, + }) + if cfg.hooks.setup.io ~= nil then + vim.validate({ io = { cfg.hooks.setup.io, 'table' } }) + vim.validate({ + input = { cfg.hooks.setup.io.input, { 'function', 'nil' }, true }, + output = { cfg.hooks.setup.io.output, { 'function', 'nil' }, true }, + }) + end + end + if cfg.hooks.on ~= nil then + vim.validate({ on = { cfg.hooks.on, 'table' } }) + vim.validate({ + enter = { cfg.hooks.on.enter, { 'function', 'nil' }, true }, + run = { cfg.hooks.on.run, { 'function', 'nil' }, true }, + debug = { cfg.hooks.on.debug, { 'function', 'nil' }, true }, + }) + end local layouts = require('cp.ui.layouts') vim.validate({ diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 43a50c0..46c3dc0 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -196,13 +196,6 @@ function M.setup_contest(platform, contest_id, problem_id, language) state.set_language(lang) - if cfg.hooks and cfg.hooks.setup_code and not vim.b[bufnr].cp_setup_done then - local ok = pcall(cfg.hooks.setup_code, state) - if ok then - vim.b[bufnr].cp_setup_done = true - end - end - state.set_provisional({ bufnr = bufnr, platform = platform, @@ -281,7 +274,15 @@ function M.setup_problem(problem_id, language) return end - vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p') + local contest_dir = vim.fn.fnamemodify(source_file, ':h') + local is_new_dir = vim.fn.isdirectory(contest_dir) == 0 + vim.fn.mkdir(contest_dir, 'p') + if is_new_dir then + local s = config.hooks and config.hooks.setup + if s and s.contest then + pcall(s.contest, state) + end + end local prov = state.get_provisional() if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then @@ -302,15 +303,23 @@ function M.setup_problem(problem_id, language) state.set_solution_win(vim.api.nvim_get_current_win()) if not vim.b[prov.bufnr].cp_setup_done then apply_template(prov.bufnr, lang, platform) - if config.hooks and config.hooks.setup_code then - local ok = pcall(config.hooks.setup_code, state) - if ok then - vim.b[prov.bufnr].cp_setup_done = true - end + local s = config.hooks and config.hooks.setup + if s and s.code then + local ok = pcall(s.code, state) + if ok then vim.b[prov.bufnr].cp_setup_done = true end else helpers.clearcol(prov.bufnr) vim.b[prov.bufnr].cp_setup_done = true end + local o = config.hooks and config.hooks.on + if o and o.enter then + local bufnr = prov.bufnr + vim.api.nvim_create_autocmd('BufEnter', { + buffer = bufnr, + callback = function() pcall(o.enter, state) end, + }) + pcall(o.enter, state) + end end cache.set_file_state( vim.fn.fnamemodify(source_file, ':p'), @@ -339,15 +348,22 @@ function M.setup_problem(problem_id, language) if is_new then apply_template(bufnr, lang, platform) end - if config.hooks and config.hooks.setup_code then - local ok = pcall(config.hooks.setup_code, state) - if ok then - vim.b[bufnr].cp_setup_done = true - end + local s = config.hooks and config.hooks.setup + if s and s.code then + local ok = pcall(s.code, state) + if ok then vim.b[bufnr].cp_setup_done = true end else helpers.clearcol(bufnr) vim.b[bufnr].cp_setup_done = true end + local o = config.hooks and config.hooks.on + if o and o.enter then + vim.api.nvim_create_autocmd('BufEnter', { + buffer = bufnr, + callback = function() pcall(o.enter, state) end, + }) + pcall(o.enter, state) + end end cache.set_file_state( vim.fn.expand('%:p'), diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 2d67569..c6d1281 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -444,12 +444,12 @@ function M.ensure_io_view() local cfg = config_module.get_config() - if cfg.hooks and cfg.hooks.setup_io_output then - pcall(cfg.hooks.setup_io_output, output_buf, state) + local io = cfg.hooks and cfg.hooks.setup and cfg.hooks.setup.io + if io and io.output then + pcall(io.output, output_buf, state) end - - if cfg.hooks and cfg.hooks.setup_io_input then - pcall(cfg.hooks.setup_io_input, input_buf, state) + if io and io.input then + pcall(io.input, input_buf, state) end local test_cases = cache.get_test_cases(platform, contest_id, problem_id) @@ -958,15 +958,12 @@ function M.toggle_panel(panel_opts) setup_keybindings_for_buffer(test_buffers.tab_buf) - if config.hooks and config.hooks.before_run then - vim.schedule_wrap(function() - config.hooks.before_run(state) - end) + local o = config.hooks and config.hooks.on + if o and o.run then + vim.schedule(function() o.run(state) end) end - if panel_opts and panel_opts.debug and config.hooks and config.hooks.before_debug then - vim.schedule_wrap(function() - config.hooks.before_debug(state) - end) + if panel_opts and panel_opts.debug and o and o.debug then + vim.schedule(function() o.debug(state) end) end vim.api.nvim_set_current_win(test_windows.tab_win) From 0e88e0f1827ef508bb8599aa1d3a9943f669e437 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 00:43:43 -0500 Subject: [PATCH 046/114] fix(utils): skip uv python setup on NixOS Problem: uv downloads glibc-linked Python binaries that NixOS cannot run, causing setup_python_env to fail with exit status 127. Solution: detect NixOS via /etc/NIXOS and bypass the uv sync path, falling through directly to nix-based Python discovery. --- lua/cp/utils.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 285ebf8..d300ee7 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -175,7 +175,9 @@ function M.setup_python_env() return true end - if vim.fn.executable('uv') == 1 then + local on_nixos = vim.fn.filereadable('/etc/NIXOS') == 1 + + if not on_nixos and vim.fn.executable('uv') == 1 then local plugin_path = M.get_plugin_path() logger.log('Python env: uv sync (dir=' .. plugin_path .. ')') vim.notify('[cp.nvim] Setting up Python environment...', vim.log.levels.INFO) From 72ea6249f4d2c9e259c318ce2c98857ddf8776cc Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 00:45:35 -0500 Subject: [PATCH 047/114] ci: format --- lua/cp/config.lua | 12 ++++++------ lua/cp/setup.lua | 16 ++++++++++++---- lua/cp/ui/views.lua | 8 ++++++-- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 7ca81eb..2dd2b32 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -169,15 +169,15 @@ M.defaults = { hooks = { setup = { contest = nil, - code = nil, + code = nil, io = { - input = helpers.clearcol, + input = helpers.clearcol, output = helpers.clearcol, }, }, on = { enter = nil, - run = nil, + run = nil, debug = nil, }, }, @@ -376,12 +376,12 @@ function M.setup(user_config) vim.validate({ setup = { cfg.hooks.setup, 'table' } }) vim.validate({ contest = { cfg.hooks.setup.contest, { 'function', 'nil' }, true }, - code = { cfg.hooks.setup.code, { 'function', 'nil' }, true }, + code = { cfg.hooks.setup.code, { 'function', 'nil' }, true }, }) if cfg.hooks.setup.io ~= nil then vim.validate({ io = { cfg.hooks.setup.io, 'table' } }) vim.validate({ - input = { cfg.hooks.setup.io.input, { 'function', 'nil' }, true }, + input = { cfg.hooks.setup.io.input, { 'function', 'nil' }, true }, output = { cfg.hooks.setup.io.output, { 'function', 'nil' }, true }, }) end @@ -390,7 +390,7 @@ function M.setup(user_config) vim.validate({ on = { cfg.hooks.on, 'table' } }) vim.validate({ enter = { cfg.hooks.on.enter, { 'function', 'nil' }, true }, - run = { cfg.hooks.on.run, { 'function', 'nil' }, true }, + run = { cfg.hooks.on.run, { 'function', 'nil' }, true }, debug = { cfg.hooks.on.debug, { 'function', 'nil' }, true }, }) end diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 46c3dc0..1bef5f5 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -306,7 +306,9 @@ function M.setup_problem(problem_id, language) local s = config.hooks and config.hooks.setup if s and s.code then local ok = pcall(s.code, state) - if ok then vim.b[prov.bufnr].cp_setup_done = true end + if ok then + vim.b[prov.bufnr].cp_setup_done = true + end else helpers.clearcol(prov.bufnr) vim.b[prov.bufnr].cp_setup_done = true @@ -316,7 +318,9 @@ function M.setup_problem(problem_id, language) local bufnr = prov.bufnr vim.api.nvim_create_autocmd('BufEnter', { buffer = bufnr, - callback = function() pcall(o.enter, state) end, + callback = function() + pcall(o.enter, state) + end, }) pcall(o.enter, state) end @@ -351,7 +355,9 @@ function M.setup_problem(problem_id, language) local s = config.hooks and config.hooks.setup if s and s.code then local ok = pcall(s.code, state) - if ok then vim.b[bufnr].cp_setup_done = true end + if ok then + vim.b[bufnr].cp_setup_done = true + end else helpers.clearcol(bufnr) vim.b[bufnr].cp_setup_done = true @@ -360,7 +366,9 @@ function M.setup_problem(problem_id, language) if o and o.enter then vim.api.nvim_create_autocmd('BufEnter', { buffer = bufnr, - callback = function() pcall(o.enter, state) end, + callback = function() + pcall(o.enter, state) + end, }) pcall(o.enter, state) end diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index c6d1281..c2c1c31 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -960,10 +960,14 @@ function M.toggle_panel(panel_opts) local o = config.hooks and config.hooks.on if o and o.run then - vim.schedule(function() o.run(state) end) + vim.schedule(function() + o.run(state) + end) end if panel_opts and panel_opts.debug and o and o.debug then - vim.schedule(function() o.debug(state) end) + vim.schedule(function() + o.debug(state) + end) end vim.api.nvim_set_current_win(test_windows.tab_win) From dc9cb10f3ace4d0ef64dca47d4709c0928d6ec3d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 00:50:02 -0500 Subject: [PATCH 048/114] doc: update --- doc/cp.nvim.txt | 80 +++++++++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 27563a1..b56db09 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -220,38 +220,60 @@ run CSES problems with Rust using the single schema: *cp.Hooks* Fields: ~ - {before_run} (function, optional) Called before test panel opens. - function(state: cp.State) - {before_debug} (function, optional) Called before debug build/run. - function(state: cp.State) - {setup_code} (function, optional) Called after source file is opened. - function(state: cp.State) - {setup_io_input} (function, optional) Called when I/O input buffer created. - function(bufnr: integer, state: cp.State) - Default: helpers.clearcol (removes line numbers/columns) - {setup_io_output} (function, optional) Called when I/O output buffer created. - function(bufnr: integer, state: cp.State) - Default: helpers.clearcol (removes line numbers/columns) + {setup} (|cp.CpSetupHooks|, optional) One-time initialization hooks. + {on} (|cp.CpOnHooks|, optional) Recurring event hooks. - Hook functions receive the cp.nvim state object (|cp.State|). See + *cp.CpSetupHooks* + Fields: ~ + {contest} (function, optional) Called once when a contest directory + is first created (not on subsequent visits). + function(state: cp.State) + {code} (function, optional) Called after the source buffer is + opened for the first time (guarded by cp_setup_done). + function(state: cp.State) + {io} (|cp.CpSetupIOHooks|, optional) I/O buffer hooks. + + *cp.CpSetupIOHooks* + Fields: ~ + {input} (function, optional) Called when the I/O input buffer is + created. function(bufnr: integer, state: cp.State) + Default: helpers.clearcol + {output} (function, optional) Called when the I/O output buffer is + created. function(bufnr: integer, state: cp.State) + Default: helpers.clearcol + + *cp.CpOnHooks* + Fields: ~ + {enter} (function, optional) Called on every BufEnter on the + solution buffer. Registered as a buffer-scoped autocmd and + fired immediately after setup.code. + function(state: cp.State) + {run} (function, optional) Called before the test panel opens. + function(state: cp.State) + {debug} (function, optional) Called before a debug run. + function(state: cp.State) + + All hook functions receive the cp.nvim state object (|cp.State|). See |lua/cp/state.lua| for available methods and fields. - The I/O buffer hooks are called once when the buffers are first created - during problem setup. Use these to customize buffer appearance (e.g., - remove line numbers, set custom options). Access helpers via: ->lua - local helpers = require('cp').helpers -< Example usage: >lua hooks = { - setup_code = function(state) - print("Setting up " .. state.get_base_name()) - print("Source file: " .. state.get_source_file()) - end, - setup_io_input = function(bufnr, state) - vim.api.nvim_set_option_value('number', false, { buf = bufnr }) - end + setup = { + contest = function(state) + local dir = vim.fn.fnamemodify( + state.get_source_file(state.get_language()), ':h') + vim.fn.system({ 'cp', '~/.clang-format', dir .. '/.clang-format' }) + end, + code = function(state) + vim.opt_local.foldmethod = 'marker' + vim.diagnostic.enable(false) + end, + }, + on = { + enter = function(state) vim.opt_local.winbar = '' end, + run = function(state) require('config.lsp').format() end, + }, } < @@ -657,9 +679,9 @@ While in the I/O view buffers, use the configured keymaps to cycle through tests Buffer Customization ~ -Use the setup_io_input and setup_io_output hooks (see |cp.Hooks|) to customize -buffer appearance. By default, line numbers and columns are removed via -helpers.clearcol (see |cp-helpers|). +Use the hooks.setup.io.input and hooks.setup.io.output hooks (see |cp.Hooks|) +to customize buffer appearance. By default, line numbers and columns are +removed via helpers.clearcol (see |cp-helpers|). ============================================================================== VERDICT FORMATTING *cp-verdict-format* From 865e3b59281b43e3b8ee97c4cfd5c6d87f0fd452 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:51:37 -0500 Subject: [PATCH 049/114] refactor: rename epsilon to precision in runner Problem: the tolerance field for floating-point comparison was named `epsilon`, which is an implementation detail, not the user-visible concept. Solution: rename to `precision` in run.lua type annotations, internal variables, and comparison logic. --- lua/cp/runner/run.lua | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 042baf2..306a1f8 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -19,7 +19,7 @@ ---@class ProblemConstraints ---@field timeout_ms number ---@field memory_mb number ----@field epsilon number? +---@field precision number? ---@class PanelState ---@field test_cases RanTestCase[] @@ -57,8 +57,8 @@ local function load_constraints_from_cache(platform, contest_id, problem_id) cache.load() local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id) if timeout_ms and memory_mb then - local epsilon = cache.get_epsilon(platform, contest_id, problem_id) - return { timeout_ms = timeout_ms, memory_mb = memory_mb, epsilon = epsilon } + local precision = cache.get_precision(platform, contest_id, problem_id) + return { timeout_ms = timeout_ms, memory_mb = memory_mb, precision = precision } end return nil end @@ -103,13 +103,13 @@ end ---@param actual string ---@param expected string ----@param epsilon number? +---@param precision number? ---@return boolean -local function compare_outputs(actual, expected, epsilon) +local function compare_outputs(actual, expected, precision) local norm_actual = normalize_lines(actual) local norm_expected = normalize_lines(expected) - if epsilon == nil or epsilon == 0 then + if precision == nil or precision == 0 then return norm_actual == norm_expected end @@ -134,7 +134,7 @@ local function compare_outputs(actual, expected, epsilon) local e_num = tonumber(e_tok) if a_num ~= nil and e_num ~= nil then - if math.abs(a_num - e_num) > epsilon then + if math.abs(a_num - e_num) > precision then return false end else @@ -192,9 +192,9 @@ local function run_single_test_case(test_case, debug, on_complete) end local expected = test_case.expected or '' - local epsilon = (panel_state.constraints and panel_state.constraints.epsilon) - or config.ui.panel.epsilon - local ok = compare_outputs(out, expected, epsilon) + local precision = (panel_state.constraints and panel_state.constraints.precision) + or config.ui.panel.precision + local ok = compare_outputs(out, expected, precision) local signal = r.signal if not signal and r.code and r.code >= 128 then From 90bd13580bbe75a5c15df78f2527f78cdcf7b921 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:51:42 -0500 Subject: [PATCH 050/114] feat(scraper): add precision extraction, start_time, and submit support Problem: problem pages contain floating-point precision requirements and contest start timestamps that were not being extracted or stored. The submit workflow also needed a foundation in the scraper layer. Solution: add extract_precision() to base.py and propagate through all scrapers into cache. Add start_time to ContestSummary and extract it from AtCoder and Codeforces. Add SubmitResult model, abstract submit() method, submit CLI case with get_language_id() resolution, stdin/env_extra support in run_scraper, and a full AtCoder submit implementation; stub the remaining platforms. --- lua/cp/cache.lua | 24 +++++++-- lua/cp/scraper.lua | 27 ++++++++++ lua/cp/setup.lua | 3 +- scrapers/atcoder.py | 112 ++++++++++++++++++++++++++++++++++++++--- scrapers/base.py | 46 ++++++++++++++++- scrapers/codechef.py | 9 +++- scrapers/codeforces.py | 22 ++++++-- scrapers/cses.py | 14 ++++-- scrapers/models.py | 8 +++ 9 files changed, 245 insertions(+), 20 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index da0f667..209c319 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -27,7 +27,7 @@ ---@field multi_test? boolean ---@field memory_mb? number ---@field timeout_ms? number ----@field epsilon? number +---@field precision? number ---@field combined_test? CombinedTest ---@field test_cases TestCase[] @@ -231,7 +231,8 @@ function M.set_test_cases( timeout_ms, memory_mb, interactive, - multi_test + multi_test, + precision ) vim.validate({ platform = { platform, 'string' }, @@ -243,6 +244,7 @@ function M.set_test_cases( memory_mb = { memory_mb, { 'number', 'nil' }, true }, interactive = { interactive, { 'boolean', 'nil' }, true }, multi_test = { multi_test, { 'boolean', 'nil' }, true }, + precision = { precision, { 'number', 'nil' }, true }, }) local index = cache_data[platform][contest_id].index_map[problem_id] @@ -253,6 +255,7 @@ function M.set_test_cases( cache_data[platform][contest_id].problems[index].memory_mb = memory_mb cache_data[platform][contest_id].problems[index].interactive = interactive cache_data[platform][contest_id].problems[index].multi_test = multi_test + cache_data[platform][contest_id].problems[index].precision = precision M.save() end @@ -278,7 +281,7 @@ end ---@param contest_id string ---@param problem_id? string ---@return number? -function M.get_epsilon(platform, contest_id, problem_id) +function M.get_precision(platform, contest_id, problem_id) vim.validate({ platform = { platform, 'string' }, contest_id = { contest_id, 'string' }, @@ -299,7 +302,7 @@ function M.get_epsilon(platform, contest_id, problem_id) end local problem_data = cache_data[platform][contest_id].problems[index] - return problem_data and problem_data.epsilon or nil + return problem_data and problem_data.precision or nil end ---@param file_path string @@ -349,11 +352,24 @@ function M.set_contest_summaries(platform, contests) cache_data[platform][contest.id] = cache_data[platform][contest.id] or {} cache_data[platform][contest.id].display_name = contest.display_name cache_data[platform][contest.id].name = contest.name + if contest.start_time then + cache_data[platform][contest.id].start_time = contest.start_time + end end M.save() end +---@param platform string +---@param contest_id string +---@return integer? +function M.get_contest_start_time(platform, contest_id) + if not cache_data[platform] or not cache_data[platform][contest_id] then + return nil + end + return cache_data[platform][contest_id].start_time +end + function M.clear_all() cache_data = {} M.save() diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 29cc63e..216e27e 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -56,6 +56,12 @@ local function run_scraper(platform, subcommand, args, opts) env.PYTHONPATH = '' env.CONDA_PREFIX = '' + if opts and opts.env_extra then + for k, v in pairs(opts.env_extra) do + env[k] = v + end + end + if opts and opts.ndjson then local uv = vim.uv local stdout = uv.new_pipe(false) @@ -126,6 +132,9 @@ local function run_scraper(platform, subcommand, args, opts) end local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path } + if opts and opts.stdin then + sysopts.stdin = opts.stdin + end if opts and opts.sync then local result = vim.system(cmd, sysopts):wait() return syshandle(result) @@ -228,6 +237,7 @@ function M.scrape_all_tests(platform, contest_id, callback) memory_mb = ev.memory_mb or 0, interactive = ev.interactive or false, multi_test = ev.multi_test or false, + precision = ev.precision, problem_id = ev.problem_id, }) end @@ -236,4 +246,21 @@ function M.scrape_all_tests(platform, contest_id, callback) }) end +function M.submit(platform, contest_id, problem_id, language, source_code, credentials, callback) + local creds_json = vim.json.encode(credentials) + run_scraper(platform, 'submit', { contest_id, problem_id, language }, { + stdin = source_code, + env_extra = { CP_CREDENTIALS = creds_json }, + on_exit = function(result) + if type(callback) == 'function' then + if result and result.success then + callback(result.data or { success = true }) + else + callback({ success = false, error = result and result.error or 'unknown' }) + end + end + end, + }) +end + return M diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 1bef5f5..e96c37f 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -130,7 +130,8 @@ local function start_tests(platform, contest_id, problems) ev.timeout_ms or 0, ev.memory_mb or 0, ev.interactive, - ev.multi_test + ev.multi_test, + ev.precision ) local io_state = state.get_io_view_state() diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 1b946dd..9b7fad6 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -14,13 +14,14 @@ from bs4 import BeautifulSoup, Tag from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from .base import BaseScraper +from .base import BaseScraper, extract_precision from .models import ( CombinedTest, ContestListResult, ContestSummary, MetadataResult, ProblemSummary, + SubmitResult, TestCase, TestsResult, ) @@ -121,6 +122,23 @@ def _parse_last_page(html: str) -> int: return max(nums) if nums else 1 +def _parse_start_time(tr: Tag) -> int | None: + tds = tr.select("td") + if not tds: + return None + time_el = tds[0].select_one("time.fixtime-full") + if not time_el: + return None + text = time_el.get_text(strip=True) + try: + from datetime import datetime + + dt = datetime.strptime(text, "%Y-%m-%d %H:%M:%S%z") + return int(dt.timestamp()) + except (ValueError, TypeError): + return None + + def _parse_archive_contests(html: str) -> list[ContestSummary]: soup = BeautifulSoup(html, "html.parser") tbody = soup.select_one("table.table-default tbody") or soup.select_one("tbody") @@ -139,7 +157,10 @@ def _parse_archive_contests(html: str) -> list[ContestSummary]: continue cid = m.group(1) name = a.get_text(strip=True) - out.append(ContestSummary(id=cid, name=name, display_name=name)) + start_time = _parse_start_time(tr) + out.append( + ContestSummary(id=cid, name=name, display_name=name, start_time=start_time) + ) return out @@ -169,7 +190,7 @@ def _parse_tasks_list(html: str) -> list[dict[str, str]]: return rows -def _extract_problem_info(html: str) -> tuple[int, float, bool]: +def _extract_problem_info(html: str) -> tuple[int, float, bool, float | None]: soup = BeautifulSoup(html, "html.parser") txt = soup.get_text(" ", strip=True) timeout_ms = 0 @@ -181,9 +202,10 @@ def _extract_problem_info(html: str) -> tuple[int, float, bool]: if ms: memory_mb = float(ms.group(1)) * MIB_TO_MB div = soup.select_one("#problem-statement") - txt = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True) - interactive = "This is an interactive" in txt - return timeout_ms, memory_mb, interactive + body = div.get_text(" ", strip=True) if div else soup.get_text(" ", strip=True) + interactive = "This is an interactive" in body + precision = extract_precision(body) + return timeout_ms, memory_mb, interactive, precision def _extract_samples(html: str) -> list[TestCase]: @@ -220,12 +242,13 @@ def _scrape_problem_page_sync(contest_id: str, slug: str) -> dict[str, Any]: tests = _extract_samples(html) except Exception: tests = [] - timeout_ms, memory_mb, interactive = _extract_problem_info(html) + timeout_ms, memory_mb, interactive, precision = _extract_problem_info(html) return { "tests": tests, "timeout_ms": timeout_ms, "memory_mb": memory_mb, "interactive": interactive, + "precision": precision, } @@ -241,14 +264,29 @@ def _to_problem_summaries(rows: list[dict[str, str]]) -> list[ProblemSummary]: return out +async def _fetch_upcoming_contests_async( + client: httpx.AsyncClient, +) -> list[ContestSummary]: + try: + html = await _get_async(client, f"{BASE_URL}/contests/") + return _parse_archive_contests(html) + except Exception: + return [] + + async def _fetch_all_contests_async() -> list[ContestSummary]: async with httpx.AsyncClient( limits=httpx.Limits(max_connections=100, max_keepalive_connections=100), ) as client: + upcoming = await _fetch_upcoming_contests_async(client) first_html = await _get_async(client, ARCHIVE_URL) last = _parse_last_page(first_html) out = _parse_archive_contests(first_html) if last <= 1: + seen = {c.id for c in out} + for c in upcoming: + if c.id not in seen: + out.append(c) return out tasks = [ asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}")) @@ -257,6 +295,10 @@ async def _fetch_all_contests_async() -> list[ContestSummary]: for coro in asyncio.as_completed(tasks): html = await coro out.extend(_parse_archive_contests(html)) + seen = {c.id for c in out} + for c in upcoming: + if c.id not in seen: + out.append(c) return out @@ -319,6 +361,7 @@ class AtcoderScraper(BaseScraper): "memory_mb": data.get("memory_mb", 0), "interactive": bool(data.get("interactive")), "multi_test": False, + "precision": data.get("precision"), } ), flush=True, @@ -326,6 +369,61 @@ class AtcoderScraper(BaseScraper): await asyncio.gather(*(emit(r) for r in rows)) + async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: + def _submit_sync() -> SubmitResult: + try: + login_page = _session.get(f"{BASE_URL}/login", headers=HEADERS, timeout=TIMEOUT_SECONDS) + login_page.raise_for_status() + soup = BeautifulSoup(login_page.text, "html.parser") + csrf_input = soup.find("input", {"name": "csrf_token"}) + if not csrf_input: + return SubmitResult(success=False, error="Could not find CSRF token on login page") + csrf_token = csrf_input.get("value", "") + + login_resp = _session.post( + f"{BASE_URL}/login", + data={ + "username": credentials.get("username", ""), + "password": credentials.get("password", ""), + "csrf_token": csrf_token, + }, + headers=HEADERS, + timeout=TIMEOUT_SECONDS, + ) + login_resp.raise_for_status() + + submit_page = _session.get( + f"{BASE_URL}/contests/{contest_id}/submit", + headers=HEADERS, + timeout=TIMEOUT_SECONDS, + ) + submit_page.raise_for_status() + soup = BeautifulSoup(submit_page.text, "html.parser") + csrf_input = soup.find("input", {"name": "csrf_token"}) + if not csrf_input: + return SubmitResult(success=False, error="Could not find CSRF token on submit page") + csrf_token = csrf_input.get("value", "") + + task_screen_name = f"{contest_id}_{problem_id}" + submit_resp = _session.post( + f"{BASE_URL}/contests/{contest_id}/submit", + data={ + "data.TaskScreenName": task_screen_name, + "data.LanguageId": language_id, + "sourceCode": source_code, + "csrf_token": csrf_token, + }, + headers=HEADERS, + timeout=TIMEOUT_SECONDS, + ) + submit_resp.raise_for_status() + + return SubmitResult(success=True, error="", submission_id="", verdict="submitted") + except Exception as e: + return SubmitResult(success=False, error=str(e)) + + return await asyncio.to_thread(_submit_sync) + async def main_async() -> int: if len(sys.argv) < 2: diff --git a/scrapers/base.py b/scrapers/base.py index 4b685d0..6cd1c5a 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -1,8 +1,31 @@ import asyncio +import json +import os +import re import sys from abc import ABC, abstractmethod -from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult +from .language_ids import get_language_id +from .models import CombinedTest, ContestListResult, MetadataResult, SubmitResult, TestsResult + +_PRECISION_ABS_REL_RE = re.compile( + r"(?:absolute|relative)\s+error[^.]*?10\s*[\^{]\s*\{?\s*[-\u2212]\s*(\d+)\s*\}?", + re.IGNORECASE, +) +_PRECISION_DECIMAL_RE = re.compile( + r"round(?:ed)?\s+to\s+(\d+)\s+decimal\s+place", + re.IGNORECASE, +) + + +def extract_precision(text: str) -> float | None: + m = _PRECISION_ABS_REL_RE.search(text) + if m: + return 10 ** -int(m.group(1)) + m = _PRECISION_DECIMAL_RE.search(text) + if m: + return 10 ** -int(m.group(1)) + return None class BaseScraper(ABC): @@ -19,6 +42,9 @@ class BaseScraper(ABC): @abstractmethod async def stream_tests_for_category_async(self, category_id: str) -> None: ... + @abstractmethod + async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: ... + def _usage(self) -> str: name = self.platform_name return f"Usage: {name}.py metadata | tests | contests" @@ -40,6 +66,9 @@ class BaseScraper(ABC): def _contests_error(self, msg: str) -> ContestListResult: return ContestListResult(success=False, error=msg) + def _submit_error(self, msg: str) -> SubmitResult: + return SubmitResult(success=False, error=msg) + async def _run_cli_async(self, args: list[str]) -> int: if len(args) < 2: print(self._metadata_error(self._usage()).model_dump_json()) @@ -71,6 +100,21 @@ class BaseScraper(ABC): print(result.model_dump_json()) return 0 if result.success else 1 + case "submit": + if len(args) != 5: + print(self._submit_error("Usage: submit ").model_dump_json()) + return 1 + source_code = sys.stdin.read() + creds_raw = os.environ.get("CP_CREDENTIALS", "{}") + try: + credentials = json.loads(creds_raw) + except json.JSONDecodeError: + credentials = {} + language_id = get_language_id(self.platform_name, args[4]) or args[4] + result = await self.submit(args[2], args[3], source_code, language_id, credentials) + print(result.model_dump_json()) + return 0 if result.success else 1 + case _: print( self._metadata_error( diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 46c2a1c..0e53f26 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -8,12 +8,13 @@ from typing import Any import httpx from curl_cffi import requests as curl_requests -from .base import BaseScraper +from .base import BaseScraper, extract_precision from .models import ( ContestListResult, ContestSummary, MetadataResult, ProblemSummary, + SubmitResult, TestCase, ) @@ -219,11 +220,13 @@ class CodeChefScraper(BaseScraper): ) memory_mb = _extract_memory_limit(html) interactive = False + precision = extract_precision(html) except Exception: tests = [] timeout_ms = 1000 memory_mb = 256.0 interactive = False + precision = None combined_input = "\n".join(t.input for t in tests) if tests else "" combined_expected = ( "\n".join(t.expected for t in tests) if tests else "" @@ -241,6 +244,7 @@ class CodeChefScraper(BaseScraper): "memory_mb": memory_mb, "interactive": interactive, "multi_test": False, + "precision": precision, } tasks = [run_one(problem_code) for problem_code in problems.keys()] @@ -248,6 +252,9 @@ class CodeChefScraper(BaseScraper): payload = await coro print(json.dumps(payload), flush=True) + async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: + return SubmitResult(success=False, error="CodeChef submit not yet implemented", submission_id="", verdict="") + if __name__ == "__main__": CodeChefScraper().run_cli() diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index a67489f..7863c27 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -9,12 +9,13 @@ import requests from bs4 import BeautifulSoup, Tag from curl_cffi import requests as curl_requests -from .base import BaseScraper +from .base import BaseScraper, extract_precision from .models import ( ContestListResult, ContestSummary, MetadataResult, ProblemSummary, + SubmitResult, TestCase, ) @@ -153,6 +154,7 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]: raw_samples, is_grouped = _extract_samples(b) timeout_ms, memory_mb = _extract_limits(b) interactive = _is_interactive(b) + precision = extract_precision(b.get_text(" ", strip=True)) if is_grouped and raw_samples: combined_input = f"{len(raw_samples)}\n" + "\n".join( @@ -179,6 +181,7 @@ def _parse_all_blocks(html: str) -> list[dict[str, Any]]: "memory_mb": memory_mb, "interactive": interactive, "multi_test": is_grouped, + "precision": precision, } ) return out @@ -228,11 +231,20 @@ class CodeforcesScraper(BaseScraper): contests: list[ContestSummary] = [] for c in data["result"]: - if c.get("phase") != "FINISHED": + phase = c.get("phase") + if phase not in ("FINISHED", "BEFORE", "CODING"): continue cid = str(c["id"]) name = c["name"] - contests.append(ContestSummary(id=cid, name=name, display_name=name)) + start_time = c.get("startTimeSeconds") if phase != "FINISHED" else None + contests.append( + ContestSummary( + id=cid, + name=name, + display_name=name, + start_time=start_time, + ) + ) if not contests: return self._contests_error("No contests found") @@ -263,11 +275,15 @@ class CodeforcesScraper(BaseScraper): "memory_mb": b.get("memory_mb", 0), "interactive": bool(b.get("interactive")), "multi_test": bool(b.get("multi_test", False)), + "precision": b.get("precision"), } ), flush=True, ) + async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: + return SubmitResult(success=False, error="Codeforces submit not yet implemented", submission_id="", verdict="") + if __name__ == "__main__": CodeforcesScraper().run_cli() diff --git a/scrapers/cses.py b/scrapers/cses.py index 5440b34..f840238 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -7,12 +7,13 @@ from typing import Any import httpx -from .base import BaseScraper +from .base import BaseScraper, extract_precision from .models import ( ContestListResult, ContestSummary, MetadataResult, ProblemSummary, + SubmitResult, TestCase, ) @@ -129,17 +130,21 @@ def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary] return [] -def _extract_problem_info(html: str) -> tuple[int, int, bool]: +def _extract_problem_info(html: str) -> tuple[int, int, bool, float | None]: tm = TIME_RE.search(html) mm = MEM_RE.search(html) t = int(round(float(tm.group(1)) * 1000)) if tm else 0 m = int(mm.group(1)) if mm else 0 md = MD_BLOCK_RE.search(html) interactive = False + precision = None if md: body = md.group(1) interactive = "This is an interactive problem." in body - return t, m, interactive + from bs4 import BeautifulSoup + + precision = extract_precision(BeautifulSoup(body, "html.parser").get_text(" ")) + return t, m, interactive, precision def parse_title(html: str) -> str: @@ -257,6 +262,9 @@ class CSESScraper(BaseScraper): payload = await coro print(json.dumps(payload), flush=True) + async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: + return SubmitResult(success=False, error="CSES submit not yet implemented", submission_id="", verdict="") + if __name__ == "__main__": CSESScraper().run_cli() diff --git a/scrapers/models.py b/scrapers/models.py index be0944d..68de9a9 100644 --- a/scrapers/models.py +++ b/scrapers/models.py @@ -26,6 +26,7 @@ class ContestSummary(BaseModel): id: str name: str display_name: str | None = None + start_time: int | None = None model_config = ConfigDict(extra="forbid") @@ -63,6 +64,13 @@ class TestsResult(ScrapingResult): model_config = ConfigDict(extra="forbid") +class SubmitResult(ScrapingResult): + submission_id: str = "" + verdict: str = "" + + model_config = ConfigDict(extra="forbid") + + class ScraperConfig(BaseModel): timeout_seconds: int = 30 max_retries: int = 3 From 4e8da8488246c667309fe2a41547d3637bc5f9b4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:51:47 -0500 Subject: [PATCH 051/114] feat(platforms): add kattis and usaco scrapers Add KattisScraper and USACOScraper with contest list, metadata, and test case fetching. Register kattis and usaco in PLATFORMS, PLATFORM_DISPLAY_NAMES, and default platform configs. --- lua/cp/config.lua | 20 ++- lua/cp/constants.lua | 6 +- scrapers/kattis.py | 231 ++++++++++++++++++++++++++++++++++ scrapers/usaco.py | 288 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 scrapers/kattis.py create mode 100644 scrapers/usaco.py diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 2dd2b32..fcebc0e 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -25,7 +25,7 @@ ---@class PanelConfig ---@field diff_modes string[] ---@field max_output_lines integer ----@field epsilon number? +---@field precision number? ---@class DiffGitConfig ---@field args string[] @@ -165,6 +165,14 @@ M.defaults = { enabled_languages = { 'cpp', 'python' }, default_language = 'cpp', }, + kattis = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + }, + usaco = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + }, }, hooks = { setup = { @@ -199,7 +207,11 @@ M.defaults = { add_test_key = 'ga', save_and_exit_key = 'q', }, - panel = { diff_modes = { 'side-by-side', 'git', 'vim' }, max_output_lines = 50, epsilon = nil }, + panel = { + diff_modes = { 'side-by-side', 'git', 'vim' }, + max_output_lines = 50, + precision = nil, + }, diff = { git = { args = { 'diff', '--no-index', '--word-diff=plain', '--word-diff-regex=.', '--no-prefix' }, @@ -420,8 +432,8 @@ function M.setup(user_config) end, 'positive integer', }, - epsilon = { - cfg.ui.panel.epsilon, + precision = { + cfg.ui.panel.precision, function(v) return v == nil or (type(v) == 'number' and v >= 0) end, diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 7bdaa16..c4bac3e 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -1,13 +1,15 @@ local M = {} -M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses' } -M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit' } +M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses', 'kattis', 'usaco' } +M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit', 'race', 'stress', 'submit' } M.PLATFORM_DISPLAY_NAMES = { atcoder = 'AtCoder', codechef = 'CodeChef', codeforces = 'CodeForces', cses = 'CSES', + kattis = 'Kattis', + usaco = 'USACO', } M.CPP = 'cpp' diff --git a/scrapers/kattis.py b/scrapers/kattis.py new file mode 100644 index 0000000..c98dc35 --- /dev/null +++ b/scrapers/kattis.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 + +import asyncio +import io +import json +import re +import zipfile + +import httpx + +from .base import BaseScraper +from .models import ( + ContestListResult, + ContestSummary, + MetadataResult, + ProblemSummary, + SubmitResult, + TestCase, +) + +BASE_URL = "https://open.kattis.com" +HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +} +TIMEOUT_S = 15.0 +CONNECTIONS = 8 + +TIME_RE = re.compile( + r"CPU Time limit\s*]*>\s*(\d+)\s*seconds?\s*", + re.DOTALL, +) +MEM_RE = re.compile( + r"Memory limit\s*]*>\s*(\d+)\s*MB\s*", + re.DOTALL, +) +LAST_PAGE_RE = re.compile(r"\bpage=(\d+)") + + +async def _fetch_text(client: httpx.AsyncClient, url: str) -> str: + r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S) + r.raise_for_status() + return r.text + + +async def _fetch_bytes(client: httpx.AsyncClient, url: str) -> bytes: + r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S) + r.raise_for_status() + return r.content + + +def _parse_limits(html: str) -> tuple[int, int]: + tm = TIME_RE.search(html) + mm = MEM_RE.search(html) + timeout_ms = int(tm.group(1)) * 1000 if tm else 1000 + memory_mb = int(mm.group(1)) if mm else 1024 + return timeout_ms, memory_mb + + +def _parse_samples_html(html: str) -> list[TestCase]: + tests: list[TestCase] = [] + tables = re.finditer(r']*>.*?', html, re.DOTALL) + for table_match in tables: + table_html = table_match.group(0) + pres = re.findall(r"
(.*?)
", table_html, re.DOTALL) + if len(pres) >= 2: + inp = pres[0].strip() + out = pres[1].strip() + tests.append(TestCase(input=inp, expected=out)) + return tests + + +def _parse_samples_zip(data: bytes) -> list[TestCase]: + try: + zf = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile: + return [] + inputs: dict[str, str] = {} + outputs: dict[str, str] = {} + for name in zf.namelist(): + content = zf.read(name).decode("utf-8").strip() + if name.endswith(".in"): + key = name[: -len(".in")] + inputs[key] = content + elif name.endswith(".ans"): + key = name[: -len(".ans")] + outputs[key] = content + tests: list[TestCase] = [] + for key in sorted(set(inputs) & set(outputs)): + tests.append(TestCase(input=inputs[key], expected=outputs[key])) + return tests + + +def _is_interactive(html: str) -> bool: + return "This is an interactive problem" in html + + +def _parse_problem_rows(html: str) -> list[tuple[str, str]]: + seen: set[str] = set() + out: list[tuple[str, str]] = [] + for m in re.finditer( + r'\s*([^<]+)', + html, + ): + pid = m.group(1) + name = m.group(2).strip() + if pid not in seen: + seen.add(pid) + out.append((pid, name)) + return out + + +def _parse_last_page(html: str) -> int: + nums = [int(m.group(1)) for m in LAST_PAGE_RE.finditer(html)] + return max(nums) if nums else 0 + + +class KattisScraper(BaseScraper): + @property + def platform_name(self) -> str: + return "kattis" + + async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: + try: + async with httpx.AsyncClient() as client: + html = await _fetch_text(client, f"{BASE_URL}/problems/{contest_id}") + timeout_ms, memory_mb = _parse_limits(html) + title_m = re.search(r"([^<]+)", html) + name = ( + title_m.group(1).split("\u2013")[0].strip() if title_m else contest_id + ) + return MetadataResult( + success=True, + error="", + contest_id=contest_id, + problems=[ProblemSummary(id=contest_id, name=name)], + url=f"{BASE_URL}/problems/%s", + ) + except Exception as e: + return self._metadata_error(str(e)) + + async def scrape_contest_list(self) -> ContestListResult: + try: + async with httpx.AsyncClient( + limits=httpx.Limits(max_connections=CONNECTIONS) + ) as client: + first_html = await _fetch_text( + client, f"{BASE_URL}/problems?page=0&order=problem_difficulty" + ) + last = _parse_last_page(first_html) + rows = _parse_problem_rows(first_html) + + sem = asyncio.Semaphore(CONNECTIONS) + + async def fetch_page(page: int) -> list[tuple[str, str]]: + async with sem: + html = await _fetch_text( + client, + f"{BASE_URL}/problems?page={page}&order=problem_difficulty", + ) + return _parse_problem_rows(html) + + tasks = [fetch_page(p) for p in range(1, last + 1)] + for coro in asyncio.as_completed(tasks): + rows.extend(await coro) + + seen: set[str] = set() + contests: list[ContestSummary] = [] + for pid, name in rows: + if pid not in seen: + seen.add(pid) + contests.append( + ContestSummary(id=pid, name=name, display_name=name) + ) + if not contests: + return self._contests_error("No problems found") + return ContestListResult(success=True, error="", contests=contests) + except Exception as e: + return self._contests_error(str(e)) + + async def stream_tests_for_category_async(self, category_id: str) -> None: + async with httpx.AsyncClient( + limits=httpx.Limits(max_connections=CONNECTIONS) + ) as client: + try: + html = await _fetch_text(client, f"{BASE_URL}/problems/{category_id}") + except Exception: + return + + timeout_ms, memory_mb = _parse_limits(html) + interactive = _is_interactive(html) + + tests: list[TestCase] = [] + try: + zip_data = await _fetch_bytes( + client, + f"{BASE_URL}/problems/{category_id}/file/statement/samples.zip", + ) + tests = _parse_samples_zip(zip_data) + except Exception: + tests = _parse_samples_html(html) + + combined_input = "\n".join(t.input for t in tests) if tests else "" + combined_expected = "\n".join(t.expected for t in tests) if tests else "" + + print( + json.dumps( + { + "problem_id": category_id, + "combined": { + "input": combined_input, + "expected": combined_expected, + }, + "tests": [ + {"input": t.input, "expected": t.expected} for t in tests + ], + "timeout_ms": timeout_ms, + "memory_mb": memory_mb, + "interactive": interactive, + "multi_test": False, + } + ), + flush=True, + ) + + + async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: + return SubmitResult(success=False, error="Kattis submit not yet implemented", submission_id="", verdict="") + + +if __name__ == "__main__": + KattisScraper().run_cli() diff --git a/scrapers/usaco.py b/scrapers/usaco.py new file mode 100644 index 0000000..b8c6c9f --- /dev/null +++ b/scrapers/usaco.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 + +import asyncio +import json +import re +from typing import Any + +import httpx + +from .base import BaseScraper +from .models import ( + ContestListResult, + ContestSummary, + MetadataResult, + ProblemSummary, + SubmitResult, + TestCase, +) + +BASE_URL = "http://www.usaco.org" +HEADERS = { + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +} +TIMEOUT_S = 15.0 +CONNECTIONS = 4 + +MONTHS = [ + "dec", + "jan", + "feb", + "mar", + "open", +] + +DIVISION_HEADING_RE = re.compile( + r"

.*?USACO\s+(\d{4})\s+(\w+)\s+Contest,\s+(\w+)\s*

", + re.IGNORECASE, +) +PROBLEM_BLOCK_RE = re.compile( + r"([^<]+)\s*.*?" + r"viewproblem2&cpid=(\d+)", + re.DOTALL, +) +SAMPLE_IN_RE = re.compile(r"(.*?)", re.DOTALL) +SAMPLE_OUT_RE = re.compile(r"(.*?)", re.DOTALL) +TIME_NOTE_RE = re.compile( + r"time\s+limit\s+(?:for\s+this\s+problem\s+is\s+)?(\d+)s", + re.IGNORECASE, +) +MEMORY_NOTE_RE = re.compile( + r"memory\s+limit\s+(?:for\s+this\s+problem\s+is\s+)?(\d+)\s*MB", + re.IGNORECASE, +) +RESULTS_PAGE_RE = re.compile( + r'href="index\.php\?page=([a-z]+\d{2,4}results)"', + re.IGNORECASE, +) + + +async def _fetch_text(client: httpx.AsyncClient, url: str) -> str: + r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S, follow_redirects=True) + r.raise_for_status() + return r.text + + +def _parse_results_page(html: str) -> dict[str, list[tuple[str, str]]]: + sections: dict[str, list[tuple[str, str]]] = {} + current_div: str | None = None + + parts = re.split(r"(

.*?

)", html, flags=re.DOTALL) + for part in parts: + heading_m = DIVISION_HEADING_RE.search(part) + if heading_m: + current_div = heading_m.group(3).lower() + sections.setdefault(current_div, []) + continue + if current_div is not None: + for m in PROBLEM_BLOCK_RE.finditer(part): + name = m.group(1).strip() + cpid = m.group(2) + sections[current_div].append((cpid, name)) + + return sections + + +def _parse_contest_id(contest_id: str) -> tuple[str, str]: + parts = contest_id.rsplit("_", 1) + if len(parts) != 2: + return contest_id, "" + return parts[0], parts[1].lower() + + +def _results_page_slug(month_year: str) -> str: + return f"{month_year}results" + + +def _parse_problem_page(html: str) -> dict[str, Any]: + inputs = SAMPLE_IN_RE.findall(html) + outputs = SAMPLE_OUT_RE.findall(html) + tests: list[TestCase] = [] + for inp, out in zip(inputs, outputs): + tests.append( + TestCase( + input=inp.strip().replace("\r", ""), + expected=out.strip().replace("\r", ""), + ) + ) + + tm = TIME_NOTE_RE.search(html) + mm = MEMORY_NOTE_RE.search(html) + timeout_ms = int(tm.group(1)) * 1000 if tm else 4000 + memory_mb = int(mm.group(1)) if mm else 256 + + interactive = "interactive problem" in html.lower() + + return { + "tests": tests, + "timeout_ms": timeout_ms, + "memory_mb": memory_mb, + "interactive": interactive, + } + + +class USACOScraper(BaseScraper): + @property + def platform_name(self) -> str: + return "usaco" + + async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: + try: + month_year, division = _parse_contest_id(contest_id) + if not division: + return self._metadata_error( + f"Invalid contest ID '{contest_id}'. " + "Expected format: _ (e.g. dec24_gold)" + ) + + slug = _results_page_slug(month_year) + async with httpx.AsyncClient() as client: + html = await _fetch_text(client, f"{BASE_URL}/index.php?page={slug}") + sections = _parse_results_page(html) + problems_raw = sections.get(division, []) + if not problems_raw: + return self._metadata_error( + f"No problems found for {contest_id} (division: {division})" + ) + problems = [ + ProblemSummary(id=cpid, name=name) for cpid, name in problems_raw + ] + return MetadataResult( + success=True, + error="", + contest_id=contest_id, + problems=problems, + url=f"{BASE_URL}/index.php?page=viewproblem2&cpid=%s", + ) + except Exception as e: + return self._metadata_error(str(e)) + + async def scrape_contest_list(self) -> ContestListResult: + try: + async with httpx.AsyncClient( + limits=httpx.Limits(max_connections=CONNECTIONS) + ) as client: + html = await _fetch_text(client, f"{BASE_URL}/index.php?page=contests") + + page_slugs: set[str] = set() + for m in RESULTS_PAGE_RE.finditer(html): + page_slugs.add(m.group(1)) + + recent_patterns = [] + for year in range(15, 27): + for month in MONTHS: + recent_patterns.append(f"{month}{year:02d}results") + page_slugs.update(recent_patterns) + + contests: list[ContestSummary] = [] + sem = asyncio.Semaphore(CONNECTIONS) + + async def check_page(slug: str) -> list[ContestSummary]: + async with sem: + try: + page_html = await _fetch_text( + client, f"{BASE_URL}/index.php?page={slug}" + ) + except Exception: + return [] + sections = _parse_results_page(page_html) + if not sections: + return [] + month_year = slug.replace("results", "") + out: list[ContestSummary] = [] + for div in sections: + cid = f"{month_year}_{div}" + year_m = re.search(r"\d{2,4}", month_year) + month_m = re.search(r"[a-z]+", month_year) + year_str = year_m.group() if year_m else "" + month_str = month_m.group().capitalize() if month_m else "" + if len(year_str) == 2: + year_str = f"20{year_str}" + display = ( + f"USACO {year_str} {month_str} - {div.capitalize()}" + ) + out.append( + ContestSummary(id=cid, name=cid, display_name=display) + ) + return out + + tasks = [check_page(slug) for slug in sorted(page_slugs)] + for coro in asyncio.as_completed(tasks): + contests.extend(await coro) + + if not contests: + return self._contests_error("No contests found") + return ContestListResult(success=True, error="", contests=contests) + except Exception as e: + return self._contests_error(str(e)) + + async def stream_tests_for_category_async(self, category_id: str) -> None: + month_year, division = _parse_contest_id(category_id) + if not division: + return + + slug = _results_page_slug(month_year) + async with httpx.AsyncClient( + limits=httpx.Limits(max_connections=CONNECTIONS) + ) as client: + try: + html = await _fetch_text(client, f"{BASE_URL}/index.php?page={slug}") + except Exception: + return + + sections = _parse_results_page(html) + problems_raw = sections.get(division, []) + if not problems_raw: + return + + sem = asyncio.Semaphore(CONNECTIONS) + + async def run_one(cpid: str) -> dict[str, Any]: + async with sem: + try: + problem_html = await _fetch_text( + client, + f"{BASE_URL}/index.php?page=viewproblem2&cpid={cpid}", + ) + info = _parse_problem_page(problem_html) + except Exception: + info = { + "tests": [], + "timeout_ms": 4000, + "memory_mb": 256, + "interactive": False, + } + + tests: list[TestCase] = info["tests"] + combined_input = "\n".join(t.input for t in tests) if tests else "" + combined_expected = ( + "\n".join(t.expected for t in tests) if tests else "" + ) + + return { + "problem_id": cpid, + "combined": { + "input": combined_input, + "expected": combined_expected, + }, + "tests": [ + {"input": t.input, "expected": t.expected} for t in tests + ], + "timeout_ms": info["timeout_ms"], + "memory_mb": info["memory_mb"], + "interactive": info["interactive"], + "multi_test": False, + } + + tasks = [run_one(cpid) for cpid, _ in problems_raw] + for coro in asyncio.as_completed(tasks): + payload = await coro + print(json.dumps(payload), flush=True) + + + async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: + return SubmitResult(success=False, error="USACO submit not yet implemented", submission_id="", verdict="") + + +if __name__ == "__main__": + USACOScraper().run_cli() From f5c1b978a2ff1ce672bb9c6548815a17e96fe3cd Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:51:51 -0500 Subject: [PATCH 052/114] feat(race): add contest countdown timer Add race.lua with a 1-second vim.uv timer that counts down to a contest start time and auto-calls setup.setup_contest() at T=0. Exposes M.start(), M.stop(), and M.status() for command dispatch and statusline integration. --- lua/cp/race.lua | 143 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 lua/cp/race.lua diff --git a/lua/cp/race.lua b/lua/cp/race.lua new file mode 100644 index 0000000..689ed41 --- /dev/null +++ b/lua/cp/race.lua @@ -0,0 +1,143 @@ +local M = {} + +local cache = require('cp.cache') +local constants = require('cp.constants') +local logger = require('cp.log') +local scraper = require('cp.scraper') + +local race_state = { + timer = nil, + platform = nil, + contest_id = nil, + language = nil, + start_time = nil, +} + +local function format_countdown(seconds) + local h = math.floor(seconds / 3600) + local m = math.floor((seconds % 3600) / 60) + local s = seconds % 60 + return string.format('%02d:%02d:%02d', h, m, s) +end + +function M.start(platform, contest_id, language) + if not platform or not vim.tbl_contains(constants.PLATFORMS, platform) then + logger.log('Invalid platform', vim.log.levels.ERROR) + return + end + if not contest_id or contest_id == '' then + logger.log('Contest ID required', vim.log.levels.ERROR) + return + end + if race_state.timer then + logger.log('Race already active. Use :CP race stop first.', vim.log.levels.WARN) + return + end + + cache.load() + local start_time = cache.get_contest_start_time(platform, contest_id) + + if not start_time then + logger.log('Fetching contest list...', vim.log.levels.INFO, true) + local contests = scraper.scrape_contest_list(platform) + if contests and #contests > 0 then + cache.set_contest_summaries(platform, contests) + start_time = cache.get_contest_start_time(platform, contest_id) + end + end + + if not start_time then + logger.log( + ('No start time found for %s contest %s'):format( + constants.PLATFORM_DISPLAY_NAMES[platform] or platform, + contest_id + ), + vim.log.levels.ERROR + ) + return + end + + local remaining = start_time - os.time() + if remaining <= 0 then + logger.log('Contest has already started, setting up...', vim.log.levels.INFO, true) + require('cp.setup').setup_contest(platform, contest_id, nil, language) + return + end + + race_state.platform = platform + race_state.contest_id = contest_id + race_state.language = language + race_state.start_time = start_time + + logger.log( + ('Race started for %s %s — %s remaining'):format( + constants.PLATFORM_DISPLAY_NAMES[platform] or platform, + contest_id, + format_countdown(remaining) + ), + vim.log.levels.INFO, + true + ) + + local timer = vim.uv.new_timer() + race_state.timer = timer + timer:start( + 1000, + 1000, + vim.schedule_wrap(function() + local r = race_state.start_time - os.time() + if r <= 0 then + timer:stop() + timer:close() + race_state.timer = nil + local p = race_state.platform + local c = race_state.contest_id + local l = race_state.language + race_state.platform = nil + race_state.contest_id = nil + race_state.language = nil + race_state.start_time = nil + logger.log('Contest started!', vim.log.levels.INFO, true) + require('cp.setup').setup_contest(p, c, nil, l) + else + vim.notify( + ('[cp.nvim] %s %s — %s'):format( + constants.PLATFORM_DISPLAY_NAMES[race_state.platform] or race_state.platform, + race_state.contest_id, + format_countdown(r) + ), + vim.log.levels.INFO + ) + end + end) + ) +end + +function M.stop() + if not race_state.timer then + logger.log('No active race', vim.log.levels.WARN) + return + end + race_state.timer:stop() + race_state.timer:close() + race_state.timer = nil + race_state.platform = nil + race_state.contest_id = nil + race_state.language = nil + race_state.start_time = nil + logger.log('Race cancelled', vim.log.levels.INFO, true) +end + +function M.status() + if not race_state.timer or not race_state.start_time then + return { active = false } + end + return { + active = true, + platform = race_state.platform, + contest_id = race_state.contest_id, + remaining_seconds = math.max(0, race_state.start_time - os.time()), + } +end + +return M From 39b7b3d83f8269b1e83b00507e3091054ea8badb Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:51:56 -0500 Subject: [PATCH 053/114] feat(stress): add stress test loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stress.lua that auto-detects or accepts generator and brute solution files, compiles C++ if needed, and launches scripts/stress.py in a terminal buffer with session save/restore and cleanup autocmds. Add scripts/stress.py as a standalone loop that runs generator → brute → candidate, comparing outputs and exiting on the first mismatch. --- lua/cp/stress.lua | 235 ++++++++++++++++++++++++++++++++++++++++++++++ scripts/stress.py | 107 +++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 lua/cp/stress.lua create mode 100755 scripts/stress.py diff --git a/lua/cp/stress.lua b/lua/cp/stress.lua new file mode 100644 index 0000000..3e51881 --- /dev/null +++ b/lua/cp/stress.lua @@ -0,0 +1,235 @@ +local M = {} + +local logger = require('cp.log') +local state = require('cp.state') +local utils = require('cp.utils') + +local GENERATOR_PATTERNS = { + 'gen.py', + 'gen.cc', + 'gen.cpp', + 'generator.py', + 'generator.cc', + 'generator.cpp', +} + +local BRUTE_PATTERNS = { + 'brute.py', + 'brute.cc', + 'brute.cpp', + 'slow.py', + 'slow.cc', + 'slow.cpp', +} + +local function find_file(patterns) + for _, pattern in ipairs(patterns) do + if vim.fn.filereadable(pattern) == 1 then + return pattern + end + end + return nil +end + +local function compile_cpp(source, output) + local result = vim.system({ 'sh', '-c', 'g++ -O2 -o ' .. output .. ' ' .. source }):wait() + if result.code ~= 0 then + logger.log( + ('Failed to compile %s: %s'):format(source, result.stderr or ''), + vim.log.levels.ERROR + ) + return false + end + return true +end + +local function build_run_cmd(file) + local ext = file:match('%.([^%.]+)$') + if ext == 'cc' or ext == 'cpp' then + local base = file:gsub('%.[^%.]+$', '') + local bin = base .. '_bin' + if not compile_cpp(file, bin) then + return nil + end + return './' .. bin + elseif ext == 'py' then + return 'python3 ' .. file + end + return './' .. file +end + +function M.toggle(generator_cmd, brute_cmd) + if state.get_active_panel() == 'stress' then + if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then + local job = vim.b[state.stress_buf].terminal_job_id + if job then + vim.fn.jobstop(job) + end + end + if state.saved_stress_session then + vim.cmd.source(state.saved_stress_session) + vim.fn.delete(state.saved_stress_session) + state.saved_stress_session = nil + end + state.set_active_panel(nil) + return + end + + if state.get_active_panel() then + logger.log('Another panel is already active.', vim.log.levels.WARN) + return + end + + local gen_file = generator_cmd + local brute_file = brute_cmd + + if not gen_file then + gen_file = find_file(GENERATOR_PATTERNS) + end + if not brute_file then + brute_file = find_file(BRUTE_PATTERNS) + end + + if not gen_file then + logger.log( + 'No generator found. Pass generator as first arg or add gen.{py,cc,cpp}.', + vim.log.levels.ERROR + ) + return + end + if not brute_file then + logger.log( + 'No brute solution found. Pass brute as second arg or add brute.{py,cc,cpp}.', + vim.log.levels.ERROR + ) + return + end + + local gen_cmd = build_run_cmd(gen_file) + if not gen_cmd then + return + end + + local brute_run_cmd = build_run_cmd(brute_file) + if not brute_run_cmd then + return + end + + state.saved_stress_session = vim.fn.tempname() + -- selene: allow(mixed_table) + vim.cmd.mksession({ state.saved_stress_session, bang = true }) + vim.cmd.only({ mods = { silent = true } }) + + local execute = require('cp.runner.execute') + + local function restore_session() + if state.saved_stress_session then + vim.cmd.source(state.saved_stress_session) + vim.fn.delete(state.saved_stress_session) + state.saved_stress_session = nil + end + end + + execute.compile_problem(false, function(compile_result) + if not compile_result.success then + local run = require('cp.runner.run') + run.handle_compilation_failure(compile_result.output) + restore_session() + return + end + + local binary = state.get_binary_file() + if not binary or binary == '' then + logger.log('No binary produced.', vim.log.levels.ERROR) + restore_session() + return + end + + local script = vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/stress.py', ':p') + + local cmdline + if utils.is_nix_build() then + cmdline = table.concat({ + vim.fn.shellescape(utils.get_nix_python()), + vim.fn.shellescape(script), + vim.fn.shellescape(gen_cmd), + vim.fn.shellescape(brute_run_cmd), + vim.fn.shellescape(binary), + }, ' ') + else + cmdline = table.concat({ + 'uv', + 'run', + vim.fn.shellescape(script), + vim.fn.shellescape(gen_cmd), + vim.fn.shellescape(brute_run_cmd), + vim.fn.shellescape(binary), + }, ' ') + end + + vim.cmd.terminal(cmdline) + local term_buf = vim.api.nvim_get_current_buf() + local term_win = vim.api.nvim_get_current_win() + + local cleaned = false + local function cleanup() + if cleaned then + return + end + cleaned = true + if term_buf and vim.api.nvim_buf_is_valid(term_buf) then + local job = vim.b[term_buf] and vim.b[term_buf].terminal_job_id or nil + if job then + pcall(vim.fn.jobstop, job) + end + end + restore_session() + state.stress_buf = nil + state.stress_win = nil + state.set_active_panel(nil) + end + + vim.api.nvim_create_autocmd({ 'BufWipeout', 'BufUnload' }, { + buffer = term_buf, + callback = cleanup, + }) + + vim.api.nvim_create_autocmd('WinClosed', { + callback = function() + if cleaned then + return + end + local any = false + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == term_buf then + any = true + break + end + end + if not any then + cleanup() + end + end, + }) + + vim.api.nvim_create_autocmd('TermClose', { + buffer = term_buf, + callback = function() + vim.b[term_buf].cp_stress_exited = true + end, + }) + + vim.keymap.set('t', '', function() + cleanup() + end, { buffer = term_buf, silent = true }) + vim.keymap.set('n', '', function() + cleanup() + end, { buffer = term_buf, silent = true }) + + state.stress_buf = term_buf + state.stress_win = term_win + state.set_active_panel('stress') + end) +end + +return M diff --git a/scripts/stress.py b/scripts/stress.py new file mode 100755 index 0000000..429ca26 --- /dev/null +++ b/scripts/stress.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +import subprocess +import sys + + +def main() -> None: + argv = sys.argv[1:] + max_iterations = 1000 + timeout = 10 + + positional: list[str] = [] + i = 0 + while i < len(argv): + if argv[i] == "--max-iterations" and i + 1 < len(argv): + max_iterations = int(argv[i + 1]) + i += 2 + elif argv[i] == "--timeout" and i + 1 < len(argv): + timeout = int(argv[i + 1]) + i += 2 + else: + positional.append(argv[i]) + i += 1 + + if len(positional) != 3: + print( + "Usage: stress.py " + "[--max-iterations N] [--timeout S]", + file=sys.stderr, + ) + sys.exit(1) + + generator, brute, candidate = positional + + for iteration in range(1, max_iterations + 1): + try: + gen_result = subprocess.run( + generator, + capture_output=True, + text=True, + shell=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + print(f"[stress] generator timed out on iteration {iteration}", file=sys.stderr) + sys.exit(1) + + if gen_result.returncode != 0: + print( + f"[stress] generator failed on iteration {iteration} " + f"(exit code {gen_result.returncode})", + file=sys.stderr, + ) + if gen_result.stderr: + print(gen_result.stderr, file=sys.stderr, end="") + sys.exit(1) + + test_input = gen_result.stdout + + try: + brute_result = subprocess.run( + brute, + input=test_input, + capture_output=True, + text=True, + shell=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + print(f"[stress] brute timed out on iteration {iteration}", file=sys.stderr) + print(f"\n--- input ---\n{test_input}", end="") + sys.exit(1) + + try: + cand_result = subprocess.run( + candidate, + input=test_input, + capture_output=True, + text=True, + shell=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + print(f"[stress] candidate timed out on iteration {iteration}", file=sys.stderr) + print(f"\n--- input ---\n{test_input}", end="") + sys.exit(1) + + brute_out = brute_result.stdout.strip() + cand_out = cand_result.stdout.strip() + + if brute_out != cand_out: + print(f"[stress] mismatch on iteration {iteration}", file=sys.stderr) + print(f"\n--- input ---\n{test_input}", end="") + print(f"\n--- expected (brute) ---\n{brute_out}") + print(f"\n--- actual (candidate) ---\n{cand_out}") + sys.exit(1) + + print(f"[stress] iteration {iteration} OK", file=sys.stderr) + + print( + f"[stress] all {max_iterations} iterations passed", + file=sys.stderr, + ) + sys.exit(0) + + +if __name__ == "__main__": + main() From a75694e9e02912d6bca7b030bfae56bd2e54fa8d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:52:00 -0500 Subject: [PATCH 054/114] feat(submit): add solution submission UI Add submit.lua that reads credentials from a local JSON store (prompting via vim.ui.input/inputsecret on first use), reads the source file, and delegates to scraper.submit(). Add language_ids.py with platform-to- language-ID mappings for atcoder, codeforces, and cses. --- lua/cp/submit.lua | 106 +++++++++++++++++++++++++++++++++++++++ scrapers/language_ids.py | 18 +++++++ 2 files changed, 124 insertions(+) create mode 100644 lua/cp/submit.lua create mode 100644 scrapers/language_ids.py diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua new file mode 100644 index 0000000..616dbbb --- /dev/null +++ b/lua/cp/submit.lua @@ -0,0 +1,106 @@ +local M = {} + +local logger = require('cp.log') +local state = require('cp.state') + +local credentials_file = vim.fn.stdpath('data') .. '/cp-nvim-credentials.json' + +local function load_credentials(platform) + if vim.fn.filereadable(credentials_file) ~= 1 then + return nil + end + local content = vim.fn.readfile(credentials_file) + if #content == 0 then + return nil + end + local ok, data = pcall(vim.json.decode, table.concat(content, '\n')) + if not ok then + return nil + end + return data[platform] +end + +local function save_credentials(platform, creds) + local data = {} + if vim.fn.filereadable(credentials_file) == 1 then + local content = vim.fn.readfile(credentials_file) + if #content > 0 then + local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) + if ok then + data = decoded + end + end + end + data[platform] = creds + vim.fn.mkdir(vim.fn.fnamemodify(credentials_file, ':h'), 'p') + vim.fn.writefile({ vim.json.encode(data) }, credentials_file) +end + +local function prompt_credentials(platform, callback) + local saved = load_credentials(platform) + if saved and saved.username and saved.password then + callback(saved) + return + end + vim.ui.input({ prompt = platform .. ' username: ' }, function(username) + if not username or username == '' then + logger.log('Submit cancelled', vim.log.levels.WARN) + return + end + vim.fn.inputsave() + local password = vim.fn.inputsecret(platform .. ' password: ') + vim.fn.inputrestore() + if not password or password == '' then + logger.log('Submit cancelled', vim.log.levels.WARN) + return + end + local creds = { username = username, password = password } + save_credentials(platform, creds) + callback(creds) + end) +end + +function M.submit(opts) + local platform = state.get_platform() + local contest_id = state.get_contest_id() + local problem_id = state.get_problem_id() + local language = (opts and opts.language) or state.get_language() + if not platform or not contest_id or not problem_id or not language then + logger.log('No active problem. Use :CP first.', vim.log.levels.ERROR) + return + end + + local source_file = state.get_source_file() + if not source_file or vim.fn.filereadable(source_file) ~= 1 then + logger.log('Source file not found', vim.log.levels.ERROR) + return + end + + prompt_credentials(platform, function(creds) + local source_lines = vim.fn.readfile(source_file) + local source_code = table.concat(source_lines, '\n') + + require('cp.scraper').submit( + platform, + contest_id, + problem_id, + language, + source_code, + creds, + function(result) + vim.schedule(function() + if result and result.success then + logger.log('Submitted successfully', vim.log.levels.INFO, true) + else + logger.log( + 'Submit failed: ' .. (result and result.error or 'unknown error'), + vim.log.levels.ERROR + ) + end + end) + end + ) + end) +end + +return M diff --git a/scrapers/language_ids.py b/scrapers/language_ids.py new file mode 100644 index 0000000..d0a47e6 --- /dev/null +++ b/scrapers/language_ids.py @@ -0,0 +1,18 @@ +LANGUAGE_IDS = { + "atcoder": { + "cpp": "5028", + "python": "5078", + }, + "codeforces": { + "cpp": "89", + "python": "70", + }, + "cses": { + "cpp": "C++17", + "python": "Python3", + }, +} + + +def get_language_id(platform: str, language: str) -> str | None: + return LANGUAGE_IDS.get(platform, {}).get(language) From bfa2cf893c09e46d510c59c4acbea96b7bae9193 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:52:10 -0500 Subject: [PATCH 055/114] feat: wire race, stress, and submit commands and keymaps Add command parsing and dispatch for :CP race, :CP race stop, :CP stress, and :CP submit. Add tab-completion for race (platform/contest/--lang), stress (cwd executables at arg 2 and 3), and race stop. Add (cp-stress), (cp-submit), and (cp-race-stop) keymaps. --- lua/cp/commands/init.lua | 38 ++++++++++++++++++++++++++++++++++++++ plugin/cp.lua | 29 +++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 316bb5c..1e158c7 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -16,6 +16,8 @@ local actions = constants.ACTIONS ---@field platform? string ---@field problem_id? string ---@field interactor_cmd? string +---@field generator_cmd? string +---@field brute_cmd? string ---@field test_index? integer ---@field test_indices? integer[] ---@field mode? string @@ -53,6 +55,27 @@ local function parse_command(args) else return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand } end + elseif first == 'race' then + if args[2] == 'stop' then + return { type = 'action', action = 'race_stop' } + end + if not args[2] or not args[3] then + return { + type = 'error', + message = 'Usage: :CP race [--lang ]', + } + end + local language = nil + if args[4] == '--lang' and args[5] then + language = args[5] + end + return { + type = 'action', + action = 'race', + platform = args[2], + contest = args[3], + language = language, + } elseif first == 'interact' then local inter = args[2] if inter and inter ~= '' then @@ -60,6 +83,13 @@ local function parse_command(args) else return { type = 'action', action = 'interact' } end + elseif first == 'stress' then + return { + type = 'action', + action = 'stress', + generator_cmd = args[2], + brute_cmd = args[3], + } elseif first == 'edit' then local test_index = nil if #args >= 2 then @@ -285,6 +315,14 @@ function M.handle_command(opts) elseif cmd.action == 'edit' then local edit = require('cp.ui.edit') edit.toggle_edit(cmd.test_index) + elseif cmd.action == 'stress' then + require('cp.stress').toggle(cmd.generator_cmd, cmd.brute_cmd) + elseif cmd.action == 'submit' then + require('cp.submit').submit({ language = cmd.language }) + elseif cmd.action == 'race' then + require('cp.race').start(cmd.platform, cmd.contest, cmd.language) + elseif cmd.action == 'race_stop' then + require('cp.race').stop() end elseif cmd.type == 'problem_jump' then local platform = state.get_platform() diff --git a/plugin/cp.lua b/plugin/cp.lua index 2689e71..e9b159b 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -66,7 +66,7 @@ end, { return filter_candidates(contests) elseif args[2] == 'cache' then return filter_candidates({ 'clear', 'read' }) - elseif args[2] == 'interact' then + elseif args[2] == 'stress' or args[2] == 'interact' then local utils = require('cp.utils') return filter_candidates(utils.cwd_executables()) elseif args[2] == 'edit' then @@ -103,6 +103,10 @@ end, { end end return filter_candidates(candidates) + elseif args[2] == 'race' then + local candidates = { 'stop' } + vim.list_extend(candidates, platforms) + return filter_candidates(candidates) elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then return filter_candidates({ '--lang' }) else @@ -112,7 +116,15 @@ end, { end end elseif num_args == 4 then - if args[2] == 'cache' and args[3] == 'clear' then + if args[2] == 'stress' then + local utils = require('cp.utils') + return filter_candidates(utils.cwd_executables()) + elseif args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then + local cache = require('cp.cache') + cache.load() + local contests = cache.get_cached_contest_ids(args[3]) + return filter_candidates(contests) + elseif args[2] == 'cache' and args[3] == 'clear' then local candidates = vim.list_extend({}, platforms) table.insert(candidates, '') return filter_candidates(candidates) @@ -134,7 +146,9 @@ end, { return filter_candidates(candidates) end elseif num_args == 5 then - if args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then + if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) then + return filter_candidates({ '--lang' }) + elseif args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then local cache = require('cp.cache') cache.load() local contests = cache.get_cached_contest_ids(args[4]) @@ -147,7 +161,9 @@ end, { end end elseif num_args == 6 then - if vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then + if args[2] == 'race' and vim.tbl_contains(platforms, args[3]) and args[5] == '--lang' then + return filter_candidates(get_enabled_languages(args[3])) + elseif vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then return filter_candidates(get_enabled_languages(args[2])) end end @@ -168,3 +184,8 @@ vim.keymap.set('n', '(cp-next)', cp_action('next'), { desc = 'CP next prob vim.keymap.set('n', '(cp-prev)', cp_action('prev'), { desc = 'CP previous problem' }) vim.keymap.set('n', '(cp-pick)', cp_action('pick'), { desc = 'CP pick contest' }) vim.keymap.set('n', '(cp-interact)', cp_action('interact'), { desc = 'CP interactive mode' }) +vim.keymap.set('n', '(cp-stress)', cp_action('stress'), { desc = 'CP stress test' }) +vim.keymap.set('n', '(cp-submit)', cp_action('submit'), { desc = 'CP submit solution' }) +vim.keymap.set('n', '(cp-race-stop)', function() + require('cp.race').stop() +end, { desc = 'CP stop race countdown' }) From ad90d564ca8fe735e69a578234c805a947d711d3 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:52:17 -0500 Subject: [PATCH 056/114] fix(views): fix interactive guard logic and add stress panel support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: toggle_interactive() had its condition inverted — it blocked :CP interact on non-interactive problems while showing the message "This problem is interactive", and passed through on interactive ones. The panel guard in toggle_panel() was also missing a nil-check on contest_data.index_map, which could crash if the index map was absent. Solution: invert the toggle_interactive() guard to match the symmetrical pattern in toggle_view(), fix the error message to say "not interactive", and add the missing index_map guard. Also handle the stress panel type in M.disable() so :CP stress can be toggled off. --- lua/cp/ui/views.lua | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index c2c1c31..041200e 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -26,6 +26,8 @@ function M.disable() M.toggle_panel() elseif active_panel == 'interactive' then M.toggle_interactive() + elseif active_panel == 'stress' then + require('cp.stress').toggle() else logger.log(('Unknown panel type: %s'):format(tostring(active_panel))) end @@ -67,11 +69,11 @@ function M.toggle_interactive(interactor_cmd) cache.load() local contest_data = cache.get_contest_data(platform, contest_id) if - not contest_data - or not contest_data.index_map - or not contest_data.problems[contest_data.index_map[problem_id]].interactive + contest_data + and contest_data.index_map + and not contest_data.problems[contest_data.index_map[problem_id]].interactive then - logger.log('This problem is interactive. Use :CP interact.', vim.log.levels.ERROR) + logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR) return end @@ -830,6 +832,7 @@ function M.toggle_panel(panel_opts) local contest_data = cache.get_contest_data(platform, contest_id) if contest_data + and contest_data.index_map and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive then logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN) From bad219e57864520a417216288ca09e99b3bee725 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 14:58:57 -0500 Subject: [PATCH 057/114] ci: format --- lua/cp/constants.lua | 14 +++++++++++++- scrapers/atcoder.py | 25 ++++++++++++++++++++----- scrapers/base.py | 27 +++++++++++++++++++++++---- scrapers/codechef.py | 16 ++++++++++++++-- scrapers/codeforces.py | 16 ++++++++++++++-- scrapers/cses.py | 16 ++++++++++++++-- scrapers/kattis.py | 17 ++++++++++++++--- scrapers/usaco.py | 17 ++++++++++++++--- scripts/stress.py | 10 ++++++++-- 9 files changed, 134 insertions(+), 24 deletions(-) diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index c4bac3e..21e8f62 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -1,7 +1,19 @@ local M = {} M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses', 'kattis', 'usaco' } -M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit', 'race', 'stress', 'submit' } +M.ACTIONS = { + 'run', + 'panel', + 'next', + 'prev', + 'pick', + 'cache', + 'interact', + 'edit', + 'race', + 'stress', + 'submit', +} M.PLATFORM_DISPLAY_NAMES = { atcoder = 'AtCoder', diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 9b7fad6..c7549b8 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -369,15 +369,26 @@ class AtcoderScraper(BaseScraper): await asyncio.gather(*(emit(r) for r in rows)) - async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: + async def submit( + self, + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], + ) -> SubmitResult: def _submit_sync() -> SubmitResult: try: - login_page = _session.get(f"{BASE_URL}/login", headers=HEADERS, timeout=TIMEOUT_SECONDS) + login_page = _session.get( + f"{BASE_URL}/login", headers=HEADERS, timeout=TIMEOUT_SECONDS + ) login_page.raise_for_status() soup = BeautifulSoup(login_page.text, "html.parser") csrf_input = soup.find("input", {"name": "csrf_token"}) if not csrf_input: - return SubmitResult(success=False, error="Could not find CSRF token on login page") + return SubmitResult( + success=False, error="Could not find CSRF token on login page" + ) csrf_token = csrf_input.get("value", "") login_resp = _session.post( @@ -401,7 +412,9 @@ class AtcoderScraper(BaseScraper): soup = BeautifulSoup(submit_page.text, "html.parser") csrf_input = soup.find("input", {"name": "csrf_token"}) if not csrf_input: - return SubmitResult(success=False, error="Could not find CSRF token on submit page") + return SubmitResult( + success=False, error="Could not find CSRF token on submit page" + ) csrf_token = csrf_input.get("value", "") task_screen_name = f"{contest_id}_{problem_id}" @@ -418,7 +431,9 @@ class AtcoderScraper(BaseScraper): ) submit_resp.raise_for_status() - return SubmitResult(success=True, error="", submission_id="", verdict="submitted") + return SubmitResult( + success=True, error="", submission_id="", verdict="submitted" + ) except Exception as e: return SubmitResult(success=False, error=str(e)) diff --git a/scrapers/base.py b/scrapers/base.py index 6cd1c5a..ed0636b 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -6,7 +6,13 @@ import sys from abc import ABC, abstractmethod from .language_ids import get_language_id -from .models import CombinedTest, ContestListResult, MetadataResult, SubmitResult, TestsResult +from .models import ( + CombinedTest, + ContestListResult, + MetadataResult, + SubmitResult, + TestsResult, +) _PRECISION_ABS_REL_RE = re.compile( r"(?:absolute|relative)\s+error[^.]*?10\s*[\^{]\s*\{?\s*[-\u2212]\s*(\d+)\s*\}?", @@ -43,7 +49,14 @@ class BaseScraper(ABC): async def stream_tests_for_category_async(self, category_id: str) -> None: ... @abstractmethod - async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: ... + async def submit( + self, + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], + ) -> SubmitResult: ... def _usage(self) -> str: name = self.platform_name @@ -102,7 +115,11 @@ class BaseScraper(ABC): case "submit": if len(args) != 5: - print(self._submit_error("Usage: submit ").model_dump_json()) + print( + self._submit_error( + "Usage: submit " + ).model_dump_json() + ) return 1 source_code = sys.stdin.read() creds_raw = os.environ.get("CP_CREDENTIALS", "{}") @@ -111,7 +128,9 @@ class BaseScraper(ABC): except json.JSONDecodeError: credentials = {} language_id = get_language_id(self.platform_name, args[4]) or args[4] - result = await self.submit(args[2], args[3], source_code, language_id, credentials) + result = await self.submit( + args[2], args[3], source_code, language_id, credentials + ) print(result.model_dump_json()) return 0 if result.success else 1 diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 0e53f26..57ce33e 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -252,8 +252,20 @@ class CodeChefScraper(BaseScraper): payload = await coro print(json.dumps(payload), flush=True) - async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: - return SubmitResult(success=False, error="CodeChef submit not yet implemented", submission_id="", verdict="") + async def submit( + self, + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], + ) -> SubmitResult: + return SubmitResult( + success=False, + error="CodeChef submit not yet implemented", + submission_id="", + verdict="", + ) if __name__ == "__main__": diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 7863c27..c0495d8 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -281,8 +281,20 @@ class CodeforcesScraper(BaseScraper): flush=True, ) - async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: - return SubmitResult(success=False, error="Codeforces submit not yet implemented", submission_id="", verdict="") + async def submit( + self, + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], + ) -> SubmitResult: + return SubmitResult( + success=False, + error="Codeforces submit not yet implemented", + submission_id="", + verdict="", + ) if __name__ == "__main__": diff --git a/scrapers/cses.py b/scrapers/cses.py index f840238..554af4d 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -262,8 +262,20 @@ class CSESScraper(BaseScraper): payload = await coro print(json.dumps(payload), flush=True) - async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: - return SubmitResult(success=False, error="CSES submit not yet implemented", submission_id="", verdict="") + async def submit( + self, + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], + ) -> SubmitResult: + return SubmitResult( + success=False, + error="CSES submit not yet implemented", + submission_id="", + verdict="", + ) if __name__ == "__main__": diff --git a/scrapers/kattis.py b/scrapers/kattis.py index c98dc35..24bfb45 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -222,9 +222,20 @@ class KattisScraper(BaseScraper): flush=True, ) - - async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: - return SubmitResult(success=False, error="Kattis submit not yet implemented", submission_id="", verdict="") + async def submit( + self, + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], + ) -> SubmitResult: + return SubmitResult( + success=False, + error="Kattis submit not yet implemented", + submission_id="", + verdict="", + ) if __name__ == "__main__": diff --git a/scrapers/usaco.py b/scrapers/usaco.py index b8c6c9f..32372f4 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -279,9 +279,20 @@ class USACOScraper(BaseScraper): payload = await coro print(json.dumps(payload), flush=True) - - async def submit(self, contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str]) -> SubmitResult: - return SubmitResult(success=False, error="USACO submit not yet implemented", submission_id="", verdict="") + async def submit( + self, + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], + ) -> SubmitResult: + return SubmitResult( + success=False, + error="USACO submit not yet implemented", + submission_id="", + verdict="", + ) if __name__ == "__main__": diff --git a/scripts/stress.py b/scripts/stress.py index 429ca26..af40a42 100755 --- a/scripts/stress.py +++ b/scripts/stress.py @@ -41,7 +41,10 @@ def main() -> None: timeout=timeout, ) except subprocess.TimeoutExpired: - print(f"[stress] generator timed out on iteration {iteration}", file=sys.stderr) + print( + f"[stress] generator timed out on iteration {iteration}", + file=sys.stderr, + ) sys.exit(1) if gen_result.returncode != 0: @@ -80,7 +83,10 @@ def main() -> None: timeout=timeout, ) except subprocess.TimeoutExpired: - print(f"[stress] candidate timed out on iteration {iteration}", file=sys.stderr) + print( + f"[stress] candidate timed out on iteration {iteration}", + file=sys.stderr, + ) print(f"\n--- input ---\n{test_input}", end="") sys.exit(1) From de5a20c56707ed1a02bb9369cccc37a9970af412 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 15:01:59 -0500 Subject: [PATCH 058/114] fix: resolve typecheck errors in cache, atcoder, cses, and usaco Problem: lua typecheck flagged missing start_time field on ContestSummary; ty flagged BeautifulSoup Tag/NavigableString union on csrf_input.get(), a 3-tuple unpack where _extract_problem_info now returns 4 values in cses.py, and an untyped list assignment in usaco.py. Solution: add start_time? to ContestSummary LuaDoc, guard csrf_input with hasattr check and type: ignore, unpack precision from _extract_problem_info in cses.py callers, and use cast() in usaco.py. --- lua/cp/cache.lua | 1 + scrapers/atcoder.py | 8 ++++---- scrapers/cses.py | 12 ++++++++++-- scrapers/usaco.py | 4 ++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 209c319..61c6a89 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -15,6 +15,7 @@ ---@field display_name string ---@field name string ---@field id string +---@field start_time? integer ---@class CombinedTest ---@field input string diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index c7549b8..14debdc 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -385,11 +385,11 @@ class AtcoderScraper(BaseScraper): login_page.raise_for_status() soup = BeautifulSoup(login_page.text, "html.parser") csrf_input = soup.find("input", {"name": "csrf_token"}) - if not csrf_input: + if not csrf_input or not hasattr(csrf_input, "get"): return SubmitResult( success=False, error="Could not find CSRF token on login page" ) - csrf_token = csrf_input.get("value", "") + csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr] login_resp = _session.post( f"{BASE_URL}/login", @@ -411,11 +411,11 @@ class AtcoderScraper(BaseScraper): submit_page.raise_for_status() soup = BeautifulSoup(submit_page.text, "html.parser") csrf_input = soup.find("input", {"name": "csrf_token"}) - if not csrf_input: + if not csrf_input or not hasattr(csrf_input, "get"): return SubmitResult( success=False, error="Could not find CSRF token on submit page" ) - csrf_token = csrf_input.get("value", "") + csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr] task_screen_name = f"{contest_id}_{problem_id}" submit_resp = _session.post( diff --git a/scrapers/cses.py b/scrapers/cses.py index 554af4d..473558f 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -232,10 +232,17 @@ class CSESScraper(BaseScraper): try: html = await fetch_text(client, task_path(pid)) tests = parse_tests(html) - timeout_ms, memory_mb, interactive = _extract_problem_info(html) + timeout_ms, memory_mb, interactive, precision = ( + _extract_problem_info(html) + ) except Exception: tests = [] - timeout_ms, memory_mb, interactive = 0, 0, False + timeout_ms, memory_mb, interactive, precision = ( + 0, + 0, + False, + None, + ) combined_input = "\n".join(t.input for t in tests) if tests else "" combined_expected = ( @@ -255,6 +262,7 @@ class CSESScraper(BaseScraper): "memory_mb": memory_mb, "interactive": interactive, "multi_test": False, + "precision": precision, } tasks = [run_one(p.id) for p in problems] diff --git a/scrapers/usaco.py b/scrapers/usaco.py index 32372f4..565f1b5 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -3,7 +3,7 @@ import asyncio import json import re -from typing import Any +from typing import Any, cast import httpx @@ -253,7 +253,7 @@ class USACOScraper(BaseScraper): "interactive": False, } - tests: list[TestCase] = info["tests"] + tests = cast(list[TestCase], info["tests"]) combined_input = "\n".join(t.input for t in tests) if tests else "" combined_expected = ( "\n".join(t.expected for t in tests) if tests else "" From e79f992e0b28cbaa4f6e81f7a8be8cf8df35a502 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 15:08:51 -0500 Subject: [PATCH 059/114] fix: resolve lua typecheck warnings in race and scraper Problem: luals flagged undefined-field on uv timer methods because race_state.timer was untyped, and undefined-field on env_extra/stdin because they were missing from the run_scraper opts annotation. Solution: hoist race_state.timer into a typed local before the nil check so luals can narrow through it; add env_extra and stdin to the opts inline type in run_scraper. --- lua/cp/race.lua | 7 ++++--- lua/cp/scraper.lua | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lua/cp/race.lua b/lua/cp/race.lua index 689ed41..0e303ee 100644 --- a/lua/cp/race.lua +++ b/lua/cp/race.lua @@ -114,12 +114,13 @@ function M.start(platform, contest_id, language) end function M.stop() - if not race_state.timer then + local timer = race_state.timer + if not timer then logger.log('No active race', vim.log.levels.WARN) return end - race_state.timer:stop() - race_state.timer:close() + timer:stop() + timer:close() race_state.timer = nil race_state.platform = nil race_state.contest_id = nil diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 216e27e..6c1242d 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -33,7 +33,7 @@ end ---@param platform string ---@param subcommand string ---@param args string[] ----@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) } +---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table), env_extra?: table, stdin?: string } local function run_scraper(platform, subcommand, args, opts) if not utils.setup_python_env() then local msg = 'no Python environment available (install uv or nix)' From 7e48ba05cfdfa3b5d67a4a17ad9f36cbc0002b41 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 15:41:43 -0500 Subject: [PATCH 060/114] feat(kattis): rewrite scraper to support real contests Problem: scrape_contest_list paginated the entire Kattis problem database (3000+ problems) treating each as a "contest". scrape_contest_metadata only handled single-problem access. stream_tests_for_category_async could not fetch tests for multiple problems in a real contest. Solution: replace the paginated problem loop with a single GET to /contests that returns ~150 real timed contests. Add contest-aware path to scrape_contest_metadata that fetches /contests/{id}/problems and returns all problem slugs; fall back to single-problem path when the ID is not a contest. Add _stream_single_problem helper and update stream_tests_for_category_async to fan out concurrently over all contest problem slugs before falling back to the single-problem path. --- scrapers/kattis.py | 245 +++++++++++++++++++++++++++------------------ 1 file changed, 146 insertions(+), 99 deletions(-) diff --git a/scrapers/kattis.py b/scrapers/kattis.py index 24bfb45..d1675bf 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -5,6 +5,7 @@ import io import json import re import zipfile +from datetime import datetime import httpx @@ -33,7 +34,6 @@ MEM_RE = re.compile( r"Memory limit\s*]*>\s*(\d+)\s*MB\s*", re.DOTALL, ) -LAST_PAGE_RE = re.compile(r"\bpage=(\d+)") async def _fetch_text(client: httpx.AsyncClient, url: str) -> str: @@ -94,24 +94,110 @@ def _is_interactive(html: str) -> bool: return "This is an interactive problem" in html -def _parse_problem_rows(html: str) -> list[tuple[str, str]]: +def _parse_contests_page(html: str) -> list[ContestSummary]: + results: list[ContestSummary] = [] seen: set[str] = set() - out: list[tuple[str, str]] = [] - for m in re.finditer( - r'\s*([^<]+)', - html, - ): - pid = m.group(1) - name = m.group(2).strip() - if pid not in seen: - seen.add(pid) - out.append((pid, name)) - return out + for row_m in re.finditer(r"]*>(.*?)", html, re.DOTALL): + row = row_m.group(1) + link_m = re.search(r'href="/contests/([a-z0-9]+)"[^>]*>([^<]+)', row) + if not link_m: + continue + cid = link_m.group(1) + name = link_m.group(2).strip() + if cid in seen: + continue + seen.add(cid) + start_time: int | None = None + ts_m = re.search(r'data-timestamp="(\d+)"', row) + if ts_m: + start_time = int(ts_m.group(1)) + else: + time_m = re.search(r']+datetime="([^"]+)"', row) + if time_m: + try: + dt = datetime.fromisoformat(time_m.group(1).replace("Z", "+00:00")) + start_time = int(dt.timestamp()) + except Exception: + pass + results.append(ContestSummary(id=cid, name=name, start_time=start_time)) + return results -def _parse_last_page(html: str) -> int: - nums = [int(m.group(1)) for m in LAST_PAGE_RE.finditer(html)] - return max(nums) if nums else 0 +def _parse_contest_problem_list(html: str) -> list[tuple[str, str]]: + if "The problems will become available when the contest starts" in html: + return [] + results: list[tuple[str, str]] = [] + seen: set[str] = set() + for row_m in re.finditer(r"]*>(.*?)", html, re.DOTALL): + row = row_m.group(1) + link_m = re.search( + r'href="/contests/[^/]+/problems/([^"]+)"[^>]*>([^<]+)', row + ) + if not link_m: + continue + slug = link_m.group(1) + name = link_m.group(2).strip() + if slug in seen: + continue + seen.add(slug) + label_m = re.search(r"]*>\s*([A-Z])\s*", row) + label = label_m.group(1) if label_m else "" + display = f"{label} - {name}" if label else name + results.append((slug, display)) + return results + + +async def _fetch_contest_slugs( + client: httpx.AsyncClient, contest_id: str +) -> list[tuple[str, str]]: + try: + html = await _fetch_text(client, f"{BASE_URL}/contests/{contest_id}/problems") + return _parse_contest_problem_list(html) + except httpx.HTTPStatusError: + return [] + except Exception: + return [] + + +async def _stream_single_problem(client: httpx.AsyncClient, slug: str) -> None: + try: + html = await _fetch_text(client, f"{BASE_URL}/problems/{slug}") + except Exception: + return + + timeout_ms, memory_mb = _parse_limits(html) + interactive = _is_interactive(html) + + tests: list[TestCase] = [] + try: + zip_data = await _fetch_bytes( + client, + f"{BASE_URL}/problems/{slug}/file/statement/samples.zip", + ) + tests = _parse_samples_zip(zip_data) + except Exception: + tests = _parse_samples_html(html) + + combined_input = "\n".join(t.input for t in tests) if tests else "" + combined_expected = "\n".join(t.expected for t in tests) if tests else "" + + print( + json.dumps( + { + "problem_id": slug, + "combined": { + "input": combined_input, + "expected": combined_expected, + }, + "tests": [{"input": t.input, "expected": t.expected} for t in tests], + "timeout_ms": timeout_ms, + "memory_mb": memory_mb, + "interactive": interactive, + "multi_test": False, + } + ), + flush=True, + ) class KattisScraper(BaseScraper): @@ -122,57 +208,46 @@ class KattisScraper(BaseScraper): async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: try: async with httpx.AsyncClient() as client: - html = await _fetch_text(client, f"{BASE_URL}/problems/{contest_id}") - timeout_ms, memory_mb = _parse_limits(html) - title_m = re.search(r"([^<]+)", html) - name = ( - title_m.group(1).split("\u2013")[0].strip() if title_m else contest_id - ) - return MetadataResult( - success=True, - error="", - contest_id=contest_id, - problems=[ProblemSummary(id=contest_id, name=name)], - url=f"{BASE_URL}/problems/%s", - ) + slugs = await _fetch_contest_slugs(client, contest_id) + if slugs: + return MetadataResult( + success=True, + error="", + contest_id=contest_id, + problems=[ + ProblemSummary(id=slug, name=name) for slug, name in slugs + ], + url=f"{BASE_URL}/problems/%s", + ) + try: + html = await _fetch_text( + client, f"{BASE_URL}/problems/{contest_id}" + ) + except Exception as e: + return self._metadata_error(str(e)) + title_m = re.search(r"([^<]+)", html) + name = ( + title_m.group(1).split("\u2013")[0].strip() + if title_m + else contest_id + ) + return MetadataResult( + success=True, + error="", + contest_id=contest_id, + problems=[ProblemSummary(id=contest_id, name=name)], + url=f"{BASE_URL}/problems/%s", + ) except Exception as e: return self._metadata_error(str(e)) async def scrape_contest_list(self) -> ContestListResult: try: - async with httpx.AsyncClient( - limits=httpx.Limits(max_connections=CONNECTIONS) - ) as client: - first_html = await _fetch_text( - client, f"{BASE_URL}/problems?page=0&order=problem_difficulty" - ) - last = _parse_last_page(first_html) - rows = _parse_problem_rows(first_html) - - sem = asyncio.Semaphore(CONNECTIONS) - - async def fetch_page(page: int) -> list[tuple[str, str]]: - async with sem: - html = await _fetch_text( - client, - f"{BASE_URL}/problems?page={page}&order=problem_difficulty", - ) - return _parse_problem_rows(html) - - tasks = [fetch_page(p) for p in range(1, last + 1)] - for coro in asyncio.as_completed(tasks): - rows.extend(await coro) - - seen: set[str] = set() - contests: list[ContestSummary] = [] - for pid, name in rows: - if pid not in seen: - seen.add(pid) - contests.append( - ContestSummary(id=pid, name=name, display_name=name) - ) + async with httpx.AsyncClient() as client: + html = await _fetch_text(client, f"{BASE_URL}/contests") + contests = _parse_contests_page(html) if not contests: - return self._contests_error("No problems found") + return self._contests_error("No contests found") return ContestListResult(success=True, error="", contests=contests) except Exception as e: return self._contests_error(str(e)) @@ -181,46 +256,18 @@ class KattisScraper(BaseScraper): async with httpx.AsyncClient( limits=httpx.Limits(max_connections=CONNECTIONS) ) as client: - try: - html = await _fetch_text(client, f"{BASE_URL}/problems/{category_id}") - except Exception: + slugs = await _fetch_contest_slugs(client, category_id) + if slugs: + sem = asyncio.Semaphore(CONNECTIONS) + + async def emit_one(slug: str, _name: str) -> None: + async with sem: + await _stream_single_problem(client, slug) + + await asyncio.gather(*(emit_one(s, n) for s, n in slugs)) return - timeout_ms, memory_mb = _parse_limits(html) - interactive = _is_interactive(html) - - tests: list[TestCase] = [] - try: - zip_data = await _fetch_bytes( - client, - f"{BASE_URL}/problems/{category_id}/file/statement/samples.zip", - ) - tests = _parse_samples_zip(zip_data) - except Exception: - tests = _parse_samples_html(html) - - combined_input = "\n".join(t.input for t in tests) if tests else "" - combined_expected = "\n".join(t.expected for t in tests) if tests else "" - - print( - json.dumps( - { - "problem_id": category_id, - "combined": { - "input": combined_input, - "expected": combined_expected, - }, - "tests": [ - {"input": t.input, "expected": t.expected} for t in tests - ], - "timeout_ms": timeout_ms, - "memory_mb": memory_mb, - "interactive": interactive, - "multi_test": False, - } - ), - flush=True, - ) + await _stream_single_problem(client, category_id) async def submit( self, From 52cf54d05c950d117531aba28c4858f36ef82cf8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 15:56:43 -0500 Subject: [PATCH 061/114] docs: document new platforms, commands, and features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: vimdoc only covered AtCoder, Codeforces, and CSES, and had no entries for race, stress, or submit — all of which shipped in this branch. The platform list was also stale and the workflow example pointed users to the AtCoder website to submit manually. Solution: add CodeChef, USACO, and Kattis to the supported platforms list and platform-specific usage section (including Kattis's dual single-problem/full-contest behavior). Document :CP stress, :CP race, and :CP submit in the commands section, add their mappings, and add dedicated STRESS TESTING, RACE, and SUBMIT sections. Update get_active_panel() to list its return values, add the cp.race.status() API under the statusline section, and update the workflow example step 8 to use :CP submit. --- doc/cp.nvim.txt | 150 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index b56db09..4a10227 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -9,7 +9,7 @@ INTRODUCTION *cp.nvim* cp.nvim is a competitive programming plugin that automates problem setup, compilation, and testing workflow for online judges. -Supported platforms (for now!): AtCoder, Codeforces, CSES +Supported platforms: AtCoder, CodeChef, Codeforces, CSES, Kattis, USACO ============================================================================== REQUIREMENTS *cp-requirements* @@ -68,6 +68,18 @@ Configuration is done via `vim.g.cp`. Set this before using the plugin: enabled_languages = { 'cpp', 'python' }, default_language = 'cpp', }, + codechef = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + }, + usaco = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + }, + kattis = { + enabled_languages = { 'cpp', 'python' }, + default_language = 'cpp', + }, }, open_url = true, debug = false, @@ -344,6 +356,13 @@ COMMANDS *cp-commands* *cp-interact*). Otherwise, runs the source file. Only valid for interactive problems. + :CP stress [generator] [brute] + Start an automated stress test loop against a + brute-force reference. Toggles off if already + running. Without arguments, auto-detects a + generator and brute script in the working + directory. See |cp-stress|. + Navigation Commands ~ :CP next [--lang {language}] Navigate to next problem in current contest. @@ -401,6 +420,25 @@ COMMANDS *cp-commands* :CP edit 3 " Edit all, start at test 3 < + Race Commands ~ + :CP race {platform} {contest_id} [--lang {language}] + Start a countdown to the contest's scheduled + start time. At T=0, automatically runs: + :CP {platform} {contest_id} [--lang ...] + Examples: > + :CP race atcoder abc400 + :CP race codeforces 2100 --lang python +< + :CP race stop + Cancel an active race countdown. + + Submit Commands ~ + :CP submit [--lang {language}] + Submit the current solution to the online + judge. Prompts for credentials on first use + and stores them for subsequent submissions. + --lang: Submit solution for a specific language. + State Restoration ~ :CP Restore state from current file. Automatically detects platform, contest, problem, @@ -488,6 +526,15 @@ through the same code path as |:CP|. *(cp-interact)* (cp-interact) Open interactive mode. Equivalent to :CP interact. + *(cp-stress)* +(cp-stress) Run stress test loop. Equivalent to :CP stress. + + *(cp-submit)* +(cp-submit) Submit current solution. Equivalent to :CP submit. + + *(cp-race-stop)* +(cp-race-stop) Cancel active race countdown. Equivalent to :CP race stop. + Example configuration: >lua vim.keymap.set('n', 'cr', '(cp-run)') vim.keymap.set('n', 'cp', '(cp-panel)') @@ -496,6 +543,9 @@ Example configuration: >lua vim.keymap.set('n', 'cN', '(cp-prev)') vim.keymap.set('n', 'cc', '(cp-pick)') vim.keymap.set('n', 'ci', '(cp-interact)') + vim.keymap.set('n', 'cs', '(cp-stress)') + vim.keymap.set('n', 'cu', '(cp-submit)') + vim.keymap.set('n', 'cR', '(cp-race-stop)') < ============================================================================== @@ -575,6 +625,41 @@ URL format: https://cses.fi/problemset/task/{problem_id} Usage examples: > :CP cses dynamic_programming " Set up all problems in dp category +CodeChef ~ + *cp-codechef* +URL format: https://www.codechef.com/{contest_id}/problems/{problem_id} + +The contest_id is the contest code from the URL (e.g. START209). + +Usage examples: > + :CP codechef START209 " Set up codechef.com/START209 + +USACO ~ + *cp-usaco* +URL format: https://usaco.org/index.php?page=viewproblem2&cpid={cpid} + +The contest_id combines the abbreviated month, two-digit year, and division +in lowercase, joined by underscores (e.g. dec24_gold, feb23_silver). + +Usage examples: > + :CP usaco dec24_gold " Set up December 2024 Gold division + :CP usaco feb23_silver " Set up February 2023 Silver division + +Kattis ~ + *cp-kattis* +Kattis supports single-problem and full-contest modes. + +Single problem — the contest_id is the problem slug from the URL: +URL format: https://open.kattis.com/problems/{slug} + +Full contest — the contest_id is the contest ID from the URL. All problems +are set up at once with :CP next/:CP prev navigation: +URL format: https://open.kattis.com/contests/{id} + +Usage examples: > + :CP kattis primesieve " Single problem + :CP kattis t8tnpe " Full contest (all problems, A–H navigation) + ============================================================================== COMPLETE WORKFLOW EXAMPLE *cp-example* @@ -609,7 +694,9 @@ Example: Setting up and solving AtCoder contest ABC324 :CP < Automatically restores abc323 contest context -8. Submit solutions on AtCoder website +8. Submit solution: > + :CP submit +< Prompts for credentials on first use and submits to AtCoder. ============================================================================== I/O VIEW *cp-io-view* @@ -820,6 +907,55 @@ When using :CP interact {interactor}, the interactor must be executable Keymaps ~ Close the terminal and restore the previous layout. +============================================================================== +STRESS TESTING *cp-stress* + +Start an automated stress test loop to find inputs where your solution +disagrees with a brute-force reference. + +:CP stress [generator] [brute] + Start the stress loop. Toggles off if the loop is already running. + {generator} Generator script path (default: auto-detected). + {brute} Brute-force solution path (default: auto-detected). + Auto-detection looks for files named gen.* and brute.* in the CWD. + + The stress panel opens and streams results for each iteration. + On a mismatch, the failing input is displayed in the panel. + +Keymaps ~ + Close the stress panel and restore the previous layout. + +============================================================================== +RACE *cp-race* + +Count down to a contest's start time and automatically run setup at T=0. + +:CP race {platform} {contest_id} [--lang {language}] + Start a countdown timer. At T=0, automatically runs: + :CP {platform} {contest_id} [--lang {language}] + Examples: > + :CP race atcoder abc400 + :CP race codeforces 2100 --lang python +< +:CP race stop + Cancel an active race countdown. + +Statusline integration: see |cp-race-status|. + +============================================================================== +SUBMIT *cp-submit* + +Submit the current solution to the online judge. + +:CP submit [--lang {language}] + Submit the current solution. Prompts for credentials on first use + and stores them for subsequent submissions. + --lang: Override the language to submit. + + Platform support: + AtCoder Fully implemented. + Others Not yet implemented. + ============================================================================== ANSI COLORS AND HIGHLIGHTING *cp-ansi* @@ -930,7 +1066,15 @@ The following getters are available for statusline use: get_language() (string?) Language id. e.g. "cpp", "python" get_base_name() (string?) Derived filename stem. e.g. "1933a" get_source_file() (string?) Full source filename. e.g. "1933a.cc" - get_active_panel() (string?) Non-nil when the test panel is open. + get_active_panel() (string?) One of 'run', 'interactive', 'stress', or + nil when no panel is open. + +Race API ~ + *cp-race-status* + require('cp.race').status() returns a table describing the race state: + { active = false } + { active = true, platform = string, contest_id = string, + remaining_seconds = number } Recipe: vanilla statusline ~ From a08d1f0c5ed00c5978488a88f52b70dd55f15c43 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 16:21:21 -0500 Subject: [PATCH 062/114] refactor(submit): consolidate credentials into main cache file Problem: credentials were stored in a separate file, cp-nvim-credentials.json, alongside the main cp-nvim.json cache. Two files for one plugin's persistent state was unnecessary. Solution: add get_credentials/set_credentials to cache.lua, storing credentials under _credentials[platform] in the shared cache. Update clear_all() to preserve _credentials across cache wipes. Remove the separate file, load_credentials, and save_credentials from submit.lua. --- lua/cp/cache.lua | 21 +++++++++++++++++++++ lua/cp/submit.lua | 38 +++----------------------------------- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 61c6a89..60c91de 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -371,8 +371,29 @@ function M.get_contest_start_time(platform, contest_id) return cache_data[platform][contest_id].start_time end +---@param platform string +---@return table? +function M.get_credentials(platform) + if not cache_data._credentials then + return nil + end + return cache_data._credentials[platform] +end + +---@param platform string +---@param creds table +function M.set_credentials(platform, creds) + cache_data._credentials = cache_data._credentials or {} + cache_data._credentials[platform] = creds + M.save() +end + function M.clear_all() + local creds = cache_data._credentials cache_data = {} + if creds then + cache_data._credentials = creds + end M.save() end diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 616dbbb..d7f91fa 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -1,43 +1,11 @@ local M = {} +local cache = require('cp.cache') local logger = require('cp.log') local state = require('cp.state') -local credentials_file = vim.fn.stdpath('data') .. '/cp-nvim-credentials.json' - -local function load_credentials(platform) - if vim.fn.filereadable(credentials_file) ~= 1 then - return nil - end - local content = vim.fn.readfile(credentials_file) - if #content == 0 then - return nil - end - local ok, data = pcall(vim.json.decode, table.concat(content, '\n')) - if not ok then - return nil - end - return data[platform] -end - -local function save_credentials(platform, creds) - local data = {} - if vim.fn.filereadable(credentials_file) == 1 then - local content = vim.fn.readfile(credentials_file) - if #content > 0 then - local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) - if ok then - data = decoded - end - end - end - data[platform] = creds - vim.fn.mkdir(vim.fn.fnamemodify(credentials_file, ':h'), 'p') - vim.fn.writefile({ vim.json.encode(data) }, credentials_file) -end - local function prompt_credentials(platform, callback) - local saved = load_credentials(platform) + local saved = cache.get_credentials(platform) if saved and saved.username and saved.password then callback(saved) return @@ -55,7 +23,7 @@ local function prompt_credentials(platform, callback) return end local creds = { username = username, password = password } - save_credentials(platform, creds) + cache.set_credentials(platform, creds) callback(creds) end) end From 3e0b7beabf4c7f1a4db0fcce9112c4dd44e5ec0d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 16:26:48 -0500 Subject: [PATCH 063/114] feat: add :CP login command for explicit credential management Problem: credentials were only set implicitly on first :CP submit. There was no way to update wrong credentials, log out, or set credentials ahead of time without editing the cache JSON manually. Solution: add :CP login [platform] which always prompts for username and password and overwrites any saved credentials for that platform. Omitting the platform falls back to the active platform. Wire the command through constants, parse_command, handle_command, and add tab-completion (suggests platform names). Document in vimdoc under the SUBMIT section and in the commands reference. --- doc/cp.nvim.txt | 27 +++++++++++++++++++++++---- lua/cp/commands/init.lua | 8 ++++++++ lua/cp/constants.lua | 1 + lua/cp/login.lua | 32 ++++++++++++++++++++++++++++++++ plugin/cp.lua | 2 ++ 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 lua/cp/login.lua diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 4a10227..4a66764 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -433,10 +433,20 @@ COMMANDS *cp-commands* Cancel an active race countdown. Submit Commands ~ + :CP login [platform] + Set or update stored credentials for a platform. + Always prompts for username and password, + overwriting any previously saved credentials. + If [platform] is omitted, uses the active platform. + Examples: > + :CP login atcoder + :CP login codeforces +< :CP submit [--lang {language}] Submit the current solution to the online - judge. Prompts for credentials on first use - and stores them for subsequent submissions. + judge. Uses stored credentials (set via + :CP login). Prompts on first use if no + credentials are saved. --lang: Submit solution for a specific language. State Restoration ~ @@ -947,9 +957,18 @@ SUBMIT *cp-submit* Submit the current solution to the online judge. +:CP login [platform] *cp-login* + Set or update stored credentials for a platform. Always prompts for + username and password, overwriting any previously saved credentials. + Omit [platform] to use the currently active platform. + + Credentials are stored in the main cache file under _credentials. + Use this command before submitting for the first time, or any time + you need to update stored credentials. + :CP submit [--lang {language}] - Submit the current solution. Prompts for credentials on first use - and stores them for subsequent submissions. + Submit the current solution. Uses stored credentials (set via + :CP login). Prompts on first use if no credentials are saved. --lang: Override the language to submit. Platform support: diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 1e158c7..3eca340 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -83,6 +83,12 @@ local function parse_command(args) else return { type = 'action', action = 'interact' } end + elseif first == 'login' then + return { + type = 'action', + action = 'login', + platform = args[2], + } elseif first == 'stress' then return { type = 'action', @@ -317,6 +323,8 @@ function M.handle_command(opts) edit.toggle_edit(cmd.test_index) elseif cmd.action == 'stress' then require('cp.stress').toggle(cmd.generator_cmd, cmd.brute_cmd) + elseif cmd.action == 'login' then + require('cp.login').login(cmd.platform) elseif cmd.action == 'submit' then require('cp.submit').submit({ language = cmd.language }) elseif cmd.action == 'race' then diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 21e8f62..02b7089 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -13,6 +13,7 @@ M.ACTIONS = { 'race', 'stress', 'submit', + 'login', } M.PLATFORM_DISPLAY_NAMES = { diff --git a/lua/cp/login.lua b/lua/cp/login.lua new file mode 100644 index 0000000..e1e8f25 --- /dev/null +++ b/lua/cp/login.lua @@ -0,0 +1,32 @@ +local M = {} + +local cache = require('cp.cache') +local logger = require('cp.log') +local state = require('cp.state') + +function M.login(platform) + platform = platform or state.get_platform() + if not platform then + logger.log('No platform specified. Usage: :CP login ', vim.log.levels.ERROR) + return + end + + vim.ui.input({ prompt = platform .. ' username: ' }, function(username) + if not username or username == '' then + logger.log('Login cancelled', vim.log.levels.WARN) + return + end + vim.fn.inputsave() + local password = vim.fn.inputsecret(platform .. ' password: ') + vim.fn.inputrestore() + if not password or password == '' then + logger.log('Login cancelled', vim.log.levels.WARN) + return + end + cache.load() + cache.set_credentials(platform, { username = username, password = password }) + logger.log(platform .. ' credentials saved', vim.log.levels.INFO) + end) +end + +return M diff --git a/plugin/cp.lua b/plugin/cp.lua index e9b159b..51bb858 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -103,6 +103,8 @@ end, { end end return filter_candidates(candidates) + elseif args[2] == 'login' then + return filter_candidates(platforms) elseif args[2] == 'race' then local candidates = { 'stop' } vim.list_extend(candidates, platforms) From a04702d87cc93a6cb95f0f85cf431fd61539f34c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 16:42:35 -0500 Subject: [PATCH 064/114] refactor: replace :CP login with :CP credentials subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: :CP login was a poor API — no way to clear credentials without raw Lua, and the single command didn't scale to multiple operations. Solution: replace login with a :CP credentials subcommand following the same pattern as :CP cache. :CP credentials set [platform] prompts and saves; :CP credentials clear [platform] removes one or all platforms. Add cache.clear_credentials(), rename login.lua to credentials.lua, update parse/dispatch/tab-complete, and rewrite vimdoc accordingly. --- doc/cp.nvim.txt | 48 ++++++++++++++++++--------- lua/cp/cache.lua | 12 +++++++ lua/cp/commands/init.lua | 29 +++++++++++----- lua/cp/constants.lua | 2 +- lua/cp/{login.lua => credentials.lua} | 23 ++++++++++--- plugin/cp.lua | 6 ++-- 6 files changed, 88 insertions(+), 32 deletions(-) rename lua/cp/{login.lua => credentials.lua} (57%) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 4a66764..ef4135e 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -432,21 +432,29 @@ COMMANDS *cp-commands* :CP race stop Cancel an active race countdown. - Submit Commands ~ - :CP login [platform] + Credential Commands ~ + :CP credentials set [platform] Set or update stored credentials for a platform. Always prompts for username and password, overwriting any previously saved credentials. If [platform] is omitted, uses the active platform. Examples: > - :CP login atcoder - :CP login codeforces + :CP credentials set atcoder + :CP credentials set codeforces < + :CP credentials clear [platform] + Remove stored credentials. Without [platform], + clears credentials for all platforms. + Examples: > + :CP credentials clear atcoder + :CP credentials clear +< + Submit Commands ~ :CP submit [--lang {language}] Submit the current solution to the online judge. Uses stored credentials (set via - :CP login). Prompts on first use if no - credentials are saved. + :CP credentials set). Prompts on first use + if no credentials are saved. --lang: Submit solution for a specific language. State Restoration ~ @@ -952,23 +960,31 @@ Count down to a contest's start time and automatically run setup at T=0. Statusline integration: see |cp-race-status|. +============================================================================== +CREDENTIALS *cp-credentials* + +Manage stored login credentials for platform submission. + +Credentials are stored under _credentials in the main cache file +(stdpath('data')/cp-nvim.json). Use :CP cache read to inspect them. + +:CP credentials set [platform] + Set or update credentials for a platform. Always prompts for + username and password, overwriting any previously saved values. + Omit [platform] to use the currently active platform. + +:CP credentials clear [platform] + Remove stored credentials. Without [platform], clears all platforms. + ============================================================================== SUBMIT *cp-submit* Submit the current solution to the online judge. -:CP login [platform] *cp-login* - Set or update stored credentials for a platform. Always prompts for - username and password, overwriting any previously saved credentials. - Omit [platform] to use the currently active platform. - - Credentials are stored in the main cache file under _credentials. - Use this command before submitting for the first time, or any time - you need to update stored credentials. - :CP submit [--lang {language}] Submit the current solution. Uses stored credentials (set via - :CP login). Prompts on first use if no credentials are saved. + :CP credentials set). Prompts on first use if no credentials + are saved. --lang: Override the language to submit. Platform support: diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 60c91de..baea9b9 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -388,6 +388,18 @@ function M.set_credentials(platform, creds) M.save() end +---@param platform string? +function M.clear_credentials(platform) + if platform then + if cache_data._credentials then + cache_data._credentials[platform] = nil + end + else + cache_data._credentials = nil + end + M.save() +end + function M.clear_all() local creds = cache_data._credentials cache_data = {} diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 3eca340..2a6be0a 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -83,12 +83,20 @@ local function parse_command(args) else return { type = 'action', action = 'interact' } end - elseif first == 'login' then - return { - type = 'action', - action = 'login', - platform = args[2], - } + elseif first == 'credentials' then + local subcommand = args[2] + if not subcommand then + return { type = 'error', message = 'credentials command requires subcommand (set, clear)' } + end + if vim.tbl_contains({ 'set', 'clear' }, subcommand) then + return { + type = 'credentials', + subcommand = subcommand, + platform = args[3], + } + else + return { type = 'error', message = 'unknown credentials subcommand: ' .. subcommand } + end elseif first == 'stress' then return { type = 'action', @@ -323,8 +331,6 @@ function M.handle_command(opts) edit.toggle_edit(cmd.test_index) elseif cmd.action == 'stress' then require('cp.stress').toggle(cmd.generator_cmd, cmd.brute_cmd) - elseif cmd.action == 'login' then - require('cp.login').login(cmd.platform) elseif cmd.action == 'submit' then require('cp.submit').submit({ language = cmd.language }) elseif cmd.action == 'race' then @@ -360,6 +366,13 @@ function M.handle_command(opts) local setup = require('cp.setup') setup.setup_contest(platform, contest_id, problem_id, cmd.language) + elseif cmd.type == 'credentials' then + local creds = require('cp.credentials') + if cmd.subcommand == 'set' then + creds.set(cmd.platform) + elseif cmd.subcommand == 'clear' then + creds.clear(cmd.platform) + end elseif cmd.type == 'cache' then local cache_commands = require('cp.commands.cache') cache_commands.handle_cache_command(cmd) diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 02b7089..c1add6a 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -13,7 +13,7 @@ M.ACTIONS = { 'race', 'stress', 'submit', - 'login', + 'credentials', } M.PLATFORM_DISPLAY_NAMES = { diff --git a/lua/cp/login.lua b/lua/cp/credentials.lua similarity index 57% rename from lua/cp/login.lua rename to lua/cp/credentials.lua index e1e8f25..f9b18b9 100644 --- a/lua/cp/login.lua +++ b/lua/cp/credentials.lua @@ -4,29 +4,42 @@ local cache = require('cp.cache') local logger = require('cp.log') local state = require('cp.state') -function M.login(platform) +function M.set(platform) platform = platform or state.get_platform() if not platform then - logger.log('No platform specified. Usage: :CP login ', vim.log.levels.ERROR) + logger.log( + 'No platform specified. Usage: :CP credentials set ', + vim.log.levels.ERROR + ) return end vim.ui.input({ prompt = platform .. ' username: ' }, function(username) if not username or username == '' then - logger.log('Login cancelled', vim.log.levels.WARN) + logger.log('Cancelled', vim.log.levels.WARN) return end vim.fn.inputsave() local password = vim.fn.inputsecret(platform .. ' password: ') vim.fn.inputrestore() if not password or password == '' then - logger.log('Login cancelled', vim.log.levels.WARN) + logger.log('Cancelled', vim.log.levels.WARN) return end cache.load() cache.set_credentials(platform, { username = username, password = password }) - logger.log(platform .. ' credentials saved', vim.log.levels.INFO) + logger.log(platform .. ' credentials saved', vim.log.levels.INFO, true) end) end +function M.clear(platform) + cache.load() + cache.clear_credentials(platform) + if platform then + logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true) + else + logger.log('all credentials cleared', vim.log.levels.INFO, true) + end +end + return M diff --git a/plugin/cp.lua b/plugin/cp.lua index 51bb858..41e474f 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -103,8 +103,8 @@ end, { end end return filter_candidates(candidates) - elseif args[2] == 'login' then - return filter_candidates(platforms) + elseif args[2] == 'credentials' then + return filter_candidates({ 'set', 'clear' }) elseif args[2] == 'race' then local candidates = { 'stop' } vim.list_extend(candidates, platforms) @@ -126,6 +126,8 @@ end, { cache.load() local contests = cache.get_cached_contest_ids(args[3]) return filter_candidates(contests) + elseif args[2] == 'credentials' and vim.tbl_contains({ 'set', 'clear' }, args[3]) then + return filter_candidates(platforms) elseif args[2] == 'cache' and args[3] == 'clear' then local candidates = vim.list_extend({}, platforms) table.insert(candidates, '') From 488260f769f1e7e703d3f7ba2e6a776d69a41d5c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 3 Mar 2026 16:45:59 -0500 Subject: [PATCH 065/114] ci: format --- lua/cp/credentials.lua | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index f9b18b9..c7d0f49 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -7,10 +7,7 @@ local state = require('cp.state') function M.set(platform) platform = platform or state.get_platform() if not platform then - logger.log( - 'No platform specified. Usage: :CP credentials set ', - vim.log.levels.ERROR - ) + logger.log('No platform specified. Usage: :CP credentials set ', vim.log.levels.ERROR) return end From 217476f5f39dbf7792bf2b1550d4241a7298127a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 00:23:35 -0500 Subject: [PATCH 066/114] fix(scraper): coerce vim.NIL precision to nil before cache write Problem: vim.json.decode maps JSON null to vim.NIL (userdata), but cache.set_test_cases validates precision as number|nil, causing a type error on every scrape where precision is absent. Solution: guard the precision field when building the callback table, converting vim.NIL to nil. --- lua/cp/scraper.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 6c1242d..194e671 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -237,7 +237,7 @@ function M.scrape_all_tests(platform, contest_id, callback) memory_mb = ev.memory_mb or 0, interactive = ev.interactive or false, multi_test = ev.multi_test or false, - precision = ev.precision, + precision = ev.precision ~= vim.NIL and ev.precision or nil, problem_id = ev.problem_id, }) end From 4f88b19a8292682aeee5ed0b1e69668164effcf9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 00:23:52 -0500 Subject: [PATCH 067/114] refactor(run): remove I/O view test navigation keymaps Problem: / in the I/O view buffers required the cursor to leave the source file to work, re-ran the solution on each press, and gave no indication of which test was active. The workflow is better served by :CP run for a specific test or :CP panel for full inspection. Solution: remove navigate_test, next_test_key/prev_test_key config options, and the associated current_test_index state field. --- lua/cp/config.lua | 18 ------------------ lua/cp/state.lua | 1 - lua/cp/ui/views.lua | 44 -------------------------------------------- 3 files changed, 63 deletions(-) diff --git a/lua/cp/config.lua b/lua/cp/config.lua index fcebc0e..ec6d75b 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -78,8 +78,6 @@ ---@class RunConfig ---@field width number ----@field next_test_key string|nil ----@field prev_test_key string|nil ---@field format_verdict VerdictFormatter ---@class EditConfig @@ -196,8 +194,6 @@ M.defaults = { ansi = true, run = { width = 0.3, - next_test_key = '', - prev_test_key = '', format_verdict = helpers.default_verdict_formatter, }, edit = { @@ -448,20 +444,6 @@ function M.setup(user_config) end, 'decimal between 0 and 1', }, - next_test_key = { - cfg.ui.run.next_test_key, - function(v) - return v == nil or (type(v) == 'string' and #v > 0) - end, - 'nil or non-empty string', - }, - prev_test_key = { - cfg.ui.run.prev_test_key, - function(v) - return v == nil or (type(v) == 'string' and #v > 0) - end, - 'nil or non-empty string', - }, format_verdict = { cfg.ui.run.format_verdict, 'function', diff --git a/lua/cp/state.lua b/lua/cp/state.lua index 6d99cbf..f0f4850 100644 --- a/lua/cp/state.lua +++ b/lua/cp/state.lua @@ -9,7 +9,6 @@ ---@class cp.IoViewState ---@field output_buf integer ---@field input_buf integer ----@field current_test_index integer? ---@field source_buf integer? ---@class cp.State diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 041200e..0849164 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -240,7 +240,6 @@ local function get_or_create_io_buffers() state.set_io_view_state({ output_buf = output_buf, input_buf = input_buf, - current_test_index = 1, source_buf = current_source_buf, }) @@ -305,49 +304,6 @@ local function get_or_create_io_buffers() end, }) - local cfg = config_module.get_config() - local platform = state.get_platform() - local contest_id = state.get_contest_id() - local problem_id = state.get_problem_id() - - local function navigate_test(delta) - local io_view_state = state.get_io_view_state() - if not io_view_state then - return - end - if not platform or not contest_id or not problem_id then - return - end - local test_cases = cache.get_test_cases(platform, contest_id, problem_id) - if not test_cases or #test_cases == 0 then - return - end - local new_index = (io_view_state.current_test_index or 1) + delta - if new_index < 1 or new_index > #test_cases then - return - end - io_view_state.current_test_index = new_index - M.run_io_view(new_index) - end - - if cfg.ui.run.next_test_key then - vim.keymap.set('n', cfg.ui.run.next_test_key, function() - navigate_test(1) - end, { buffer = output_buf, silent = true, desc = 'Next test' }) - vim.keymap.set('n', cfg.ui.run.next_test_key, function() - navigate_test(1) - end, { buffer = input_buf, silent = true, desc = 'Next test' }) - end - - if cfg.ui.run.prev_test_key then - vim.keymap.set('n', cfg.ui.run.prev_test_key, function() - navigate_test(-1) - end, { buffer = output_buf, silent = true, desc = 'Previous test' }) - vim.keymap.set('n', cfg.ui.run.prev_test_key, function() - navigate_test(-1) - end, { buffer = input_buf, silent = true, desc = 'Previous test' }) - end - return output_buf, input_buf end From f17eb32e8c79156638cd17021acab174ac947a79 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 00:29:33 -0500 Subject: [PATCH 068/114] fix: pass in index to :CP panel --- lua/cp/ui/views.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 0849164..5e658c4 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -2,6 +2,7 @@ local M = {} ---@class PanelOpts ---@field debug? boolean +---@field test_index? integer local cache = require('cp.cache') local config_module = require('cp.config') @@ -806,6 +807,13 @@ function M.toggle_panel(panel_opts) return end + if panel_opts and panel_opts.test_index then + local test_state = run.get_panel_state() + if panel_opts.test_index >= 1 and panel_opts.test_index <= #test_state.test_cases then + test_state.current_index = panel_opts.test_index + end + end + local io_state = state.get_io_view_state() if io_state then for _, win in ipairs(vim.api.nvim_list_wins()) do From 900fd70935b5a6f7acfd2958cdb4bc492f7af57b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 00:43:33 -0500 Subject: [PATCH 069/114] fix(edit): clean up buffers on close and support :w to save Problem: closing the test editor left cp://test-N-* buffers alive, causing E95 on reopen. The nofile buftype also rejected :w, which was counterintuitive in an editable grid. Solution: delete all test buffers in toggle_edit teardown. Switch buftype to acwrite with a BufWriteCmd autocmd that persists test cases and clears the modified flag. Hoist save_all_tests above setup_keybindings so the autocmd closure can reference it. --- lua/cp/ui/edit.lua | 175 +++++++++++++++++++++++++-------------------- 1 file changed, 97 insertions(+), 78 deletions(-) diff --git a/lua/cp/ui/edit.lua b/lua/cp/ui/edit.lua index 886c50a..5157b2e 100644 --- a/lua/cp/ui/edit.lua +++ b/lua/cp/ui/edit.lua @@ -144,7 +144,7 @@ local function add_new_test() vim.api.nvim_win_set_buf(input_win, input_buf) vim.bo[input_buf].modifiable = true vim.bo[input_buf].readonly = false - vim.bo[input_buf].buftype = 'nofile' + vim.bo[input_buf].buftype = 'acwrite' vim.bo[input_buf].buflisted = false helpers.clearcol(input_buf) @@ -155,7 +155,7 @@ local function add_new_test() vim.api.nvim_win_set_buf(expected_win, expected_buf) vim.bo[expected_buf].modifiable = true vim.bo[expected_buf].readonly = false - vim.bo[expected_buf].buftype = 'nofile' + vim.bo[expected_buf].buftype = 'acwrite' vim.bo[expected_buf].buflisted = false helpers.clearcol(expected_buf) @@ -177,6 +177,80 @@ local function add_new_test() logger.log(('Added test %d'):format(new_index)) end +local function save_all_tests() + if not edit_state then + return + end + + local platform = state.get_platform() + local contest_id = state.get_contest_id() + local problem_id = state.get_problem_id() + + if not platform or not contest_id or not problem_id then + return + end + + for i, pair in ipairs(edit_state.test_buffers) do + if + vim.api.nvim_buf_is_valid(pair.input_buf) and vim.api.nvim_buf_is_valid(pair.expected_buf) + then + local input_lines = vim.api.nvim_buf_get_lines(pair.input_buf, 0, -1, false) + local expected_lines = vim.api.nvim_buf_get_lines(pair.expected_buf, 0, -1, false) + + edit_state.test_cases[i].input = table.concat(input_lines, '\n') + edit_state.test_cases[i].expected = table.concat(expected_lines, '\n') + end + end + + local contest_data = cache.get_contest_data(platform, contest_id) + local is_multi_test = contest_data.problems[contest_data.index_map[problem_id]].multi_test + or false + + local combined_input = table.concat( + vim.tbl_map(function(tc) + return tc.input + end, edit_state.test_cases), + '\n' + ) + local combined_expected = table.concat( + vim.tbl_map(function(tc) + return tc.expected + end, edit_state.test_cases), + '\n' + ) + + cache.set_test_cases( + platform, + contest_id, + problem_id, + { input = combined_input, expected = combined_expected }, + edit_state.test_cases, + edit_state.constraints and edit_state.constraints.timeout_ms or 0, + edit_state.constraints and edit_state.constraints.memory_mb or 0, + false, + is_multi_test + ) + + local config = config_module.get_config() + local base_name = config.filename and config.filename(platform, contest_id, problem_id, config) + or config_module.default_filename(contest_id, problem_id) + + vim.fn.mkdir('io', 'p') + + for i, tc in ipairs(edit_state.test_cases) do + local input_file = string.format('io/%s.%d.cpin', base_name, i) + local expected_file = string.format('io/%s.%d.cpout', base_name, i) + + local input_content = (tc.input or ''):gsub('\r', '') + local expected_content = (tc.expected or ''):gsub('\r', '') + + vim.fn.writefile(vim.split(input_content, '\n', { trimempty = true }), input_file) + vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file) + end + + logger.log('Saved all test cases') +end + ---@param buf integer setup_keybindings = function(buf) local config = config_module.get_config() @@ -243,86 +317,31 @@ setup_keybindings = function(buf) end) end, }) + + vim.api.nvim_create_autocmd('BufWriteCmd', { + group = augroup, + buffer = buf, + callback = function() + save_all_tests() + vim.bo[buf].modified = false + end, + }) end -local function save_all_tests() - if not edit_state then - return - end - - local platform = state.get_platform() - local contest_id = state.get_contest_id() - local problem_id = state.get_problem_id() - - if not platform or not contest_id or not problem_id then - return - end - - for i, pair in ipairs(edit_state.test_buffers) do - if - vim.api.nvim_buf_is_valid(pair.input_buf) and vim.api.nvim_buf_is_valid(pair.expected_buf) - then - local input_lines = vim.api.nvim_buf_get_lines(pair.input_buf, 0, -1, false) - local expected_lines = vim.api.nvim_buf_get_lines(pair.expected_buf, 0, -1, false) - - edit_state.test_cases[i].input = table.concat(input_lines, '\n') - edit_state.test_cases[i].expected = table.concat(expected_lines, '\n') - end - end - - local contest_data = cache.get_contest_data(platform, contest_id) - local is_multi_test = contest_data.problems[contest_data.index_map[problem_id]].multi_test - or false - - -- Generate combined test from individual test cases - local combined_input = table.concat( - vim.tbl_map(function(tc) - return tc.input - end, edit_state.test_cases), - '\n' - ) - local combined_expected = table.concat( - vim.tbl_map(function(tc) - return tc.expected - end, edit_state.test_cases), - '\n' - ) - - cache.set_test_cases( - platform, - contest_id, - problem_id, - { input = combined_input, expected = combined_expected }, - edit_state.test_cases, - edit_state.constraints and edit_state.constraints.timeout_ms or 0, - edit_state.constraints and edit_state.constraints.memory_mb or 0, - false, - is_multi_test - ) - - local config = config_module.get_config() - local base_name = config.filename and config.filename(platform, contest_id, problem_id, config) - or config_module.default_filename(contest_id, problem_id) - - vim.fn.mkdir('io', 'p') - - for i, tc in ipairs(edit_state.test_cases) do - local input_file = string.format('io/%s.%d.cpin', base_name, i) - local expected_file = string.format('io/%s.%d.cpout', base_name, i) - - local input_content = (tc.input or ''):gsub('\r', '') - local expected_content = (tc.expected or ''):gsub('\r', '') - - vim.fn.writefile(vim.split(input_content, '\n', { trimempty = true }), input_file) - vim.fn.writefile(vim.split(expected_content, '\n', { trimempty = true }), expected_file) - end - - logger.log('Saved all test cases') -end function M.toggle_edit(test_index) if edit_state then save_all_tests() + + for _, pair in ipairs(edit_state.test_buffers) do + if vim.api.nvim_buf_is_valid(pair.input_buf) then + vim.api.nvim_buf_delete(pair.input_buf, { force = true }) + end + if vim.api.nvim_buf_is_valid(pair.expected_buf) then + vim.api.nvim_buf_delete(pair.expected_buf, { force = true }) + end + end + edit_state = nil pcall(vim.api.nvim_clear_autocmds, { group = 'cp_edit_guard' }) @@ -411,7 +430,7 @@ function M.toggle_edit(test_index) vim.api.nvim_win_set_buf(input_win, input_buf) vim.bo[input_buf].modifiable = true vim.bo[input_buf].readonly = false - vim.bo[input_buf].buftype = 'nofile' + vim.bo[input_buf].buftype = 'acwrite' vim.bo[input_buf].buflisted = false helpers.clearcol(input_buf) @@ -421,7 +440,7 @@ function M.toggle_edit(test_index) vim.api.nvim_win_set_buf(expected_win, expected_buf) vim.bo[expected_buf].modifiable = true vim.bo[expected_buf].readonly = false - vim.bo[expected_buf].buftype = 'nofile' + vim.bo[expected_buf].buftype = 'acwrite' vim.bo[expected_buf].buflisted = false helpers.clearcol(expected_buf) From baaaa95b27b5cf461116a89d6992f13db0103512 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Mar 2026 00:50:10 -0500 Subject: [PATCH 070/114] ci: format --- lua/cp/ui/edit.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/cp/ui/edit.lua b/lua/cp/ui/edit.lua index 5157b2e..20d4e83 100644 --- a/lua/cp/ui/edit.lua +++ b/lua/cp/ui/edit.lua @@ -328,7 +328,6 @@ setup_keybindings = function(buf) }) end - function M.toggle_edit(test_index) if edit_state then save_all_tests() From 18a60da2d896398299385a2e278245303d443cce Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:47:48 -0500 Subject: [PATCH 071/114] misc (#290) fix atcoder :CP logins propagate scraper error codes --- lua/cp/scraper.lua | 16 +++++----- scrapers/atcoder.py | 73 +++++++++++++++++++++++++++++++++++++-------- 2 files changed, 68 insertions(+), 21 deletions(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 194e671..7f774c3 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -5,19 +5,19 @@ local logger = require('cp.log') local utils = require('cp.utils') local function syshandle(result) + local ok, data = pcall(vim.json.decode, result.stdout or '') + if ok then + return { success = true, data = data } + end + if result.code ~= 0 then local msg = 'Scraper failed: ' .. (result.stderr or 'Unknown error') return { success = false, error = msg } end - local ok, data = pcall(vim.json.decode, result.stdout) - if not ok then - local msg = 'Failed to parse scraper output: ' .. tostring(data) - logger.log(msg, vim.log.levels.ERROR) - return { success = false, error = msg } - end - - return { success = true, data = data } + local msg = 'Failed to parse scraper output: ' .. tostring(data) + logger.log(msg, vim.log.levels.ERROR) + return { success = false, error = msg } end ---@param env_map table diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 14debdc..f7124ed 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -2,6 +2,7 @@ import asyncio import json +import os import re import sys import time @@ -15,6 +16,7 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from .base import BaseScraper, extract_precision +from .language_ids import get_language_id from .models import ( CombinedTest, ContestListResult, @@ -378,10 +380,12 @@ class AtcoderScraper(BaseScraper): credentials: dict[str, str], ) -> SubmitResult: def _submit_sync() -> SubmitResult: + from curl_cffi import requests as curl_requests + try: - login_page = _session.get( - f"{BASE_URL}/login", headers=HEADERS, timeout=TIMEOUT_SECONDS - ) + session = curl_requests.Session(impersonate="chrome") + + login_page = session.get(f"{BASE_URL}/login", timeout=TIMEOUT_SECONDS) login_page.raise_for_status() soup = BeautifulSoup(login_page.text, "html.parser") csrf_input = soup.find("input", {"name": "csrf_token"}) @@ -391,21 +395,29 @@ class AtcoderScraper(BaseScraper): ) csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr] - login_resp = _session.post( + login_resp = session.post( f"{BASE_URL}/login", data={ "username": credentials.get("username", ""), "password": credentials.get("password", ""), "csrf_token": csrf_token, }, - headers=HEADERS, timeout=TIMEOUT_SECONDS, + allow_redirects=False, ) - login_resp.raise_for_status() + if login_resp.status_code in (301, 302): + location = login_resp.headers.get("Location", "") + if "/login" in location: + return SubmitResult( + success=False, + error="Login failed: incorrect username or password", + ) + session.get(BASE_URL + location, timeout=TIMEOUT_SECONDS) + else: + login_resp.raise_for_status() - submit_page = _session.get( + submit_page = session.get( f"{BASE_URL}/contests/{contest_id}/submit", - headers=HEADERS, timeout=TIMEOUT_SECONDS, ) submit_page.raise_for_status() @@ -418,7 +430,7 @@ class AtcoderScraper(BaseScraper): csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr] task_screen_name = f"{contest_id}_{problem_id}" - submit_resp = _session.post( + submit_resp = session.post( f"{BASE_URL}/contests/{contest_id}/submit", data={ "data.TaskScreenName": task_screen_name, @@ -426,13 +438,26 @@ class AtcoderScraper(BaseScraper): "sourceCode": source_code, "csrf_token": csrf_token, }, - headers=HEADERS, timeout=TIMEOUT_SECONDS, + allow_redirects=False, ) + if submit_resp.status_code in (301, 302): + location = submit_resp.headers.get("Location", "") + if "/submissions/me" in location: + return SubmitResult( + success=True, + error="", + submission_id="", + verdict="submitted", + ) + return SubmitResult( + success=False, + error=f"Submit may have failed: redirected to {location}", + ) submit_resp.raise_for_status() - return SubmitResult( - success=True, error="", submission_id="", verdict="submitted" + success=False, + error="Unexpected response from submit (expected redirect)", ) except Exception as e: return SubmitResult(success=False, error=str(e)) @@ -495,9 +520,31 @@ async def main_async() -> int: print(contest_result.model_dump_json()) return 0 if contest_result.success else 1 + if mode == "submit": + if len(sys.argv) != 5: + print( + SubmitResult( + success=False, + error="Usage: atcoder.py submit ", + ).model_dump_json() + ) + return 1 + source_code = sys.stdin.read() + creds_raw = os.environ.get("CP_CREDENTIALS", "{}") + try: + credentials = json.loads(creds_raw) + except json.JSONDecodeError: + credentials = {} + language_id = get_language_id("atcoder", sys.argv[4]) or sys.argv[4] + submit_result = await scraper.submit( + sys.argv[2], sys.argv[3], source_code, language_id, credentials + ) + print(submit_result.model_dump_json()) + return 0 if submit_result.success else 1 + result = MetadataResult( success=False, - error="Unknown mode. Use 'metadata ', 'tests ', or 'contests'", + error="Unknown mode. Use 'metadata ', 'tests ', 'contests', or 'submit '", url="", ) print(result.model_dump_json()) From 98ac0aa7a77eafb14df86bdb8a71e3effc8f65ca Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:53:37 -0500 Subject: [PATCH 072/114] refactor(credentials): rename set/clear to login/logout/clear (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The `set` and `clear` subcommands don't clearly convey their intent — `set` reads like a generic setter rather than an auth action, and `clear` overloads single-platform and all-platform semantics in one subcommand. ## Solution Rename `set` to `login`, split `clear` into `logout` (per-platform, defaults to active) and `clear` (all platforms). New API: - `:CP credentials login [platform]` — prompt and save credentials - `:CP credentials logout [platform]` — remove credentials for one platform - `:CP credentials clear` — remove all stored credentials --- doc/cp.nvim.txt | 34 ++++++++++++++++++++-------------- lua/cp/commands/init.lua | 20 +++++++++++++++----- lua/cp/credentials.lua | 29 +++++++++++++++++++++-------- plugin/cp.lua | 4 ++-- 4 files changed, 58 insertions(+), 29 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index ef4135e..31b9d8a 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -433,27 +433,29 @@ COMMANDS *cp-commands* Cancel an active race countdown. Credential Commands ~ - :CP credentials set [platform] + :CP credentials login [platform] Set or update stored credentials for a platform. Always prompts for username and password, overwriting any previously saved credentials. If [platform] is omitted, uses the active platform. Examples: > - :CP credentials set atcoder - :CP credentials set codeforces + :CP credentials login atcoder + :CP credentials login codeforces < - :CP credentials clear [platform] - Remove stored credentials. Without [platform], - clears credentials for all platforms. + :CP credentials logout [platform] + Remove stored credentials for a platform. + If [platform] is omitted, uses the active platform. Examples: > - :CP credentials clear atcoder - :CP credentials clear + :CP credentials logout atcoder +< + :CP credentials clear + Remove stored credentials for all platforms. < Submit Commands ~ :CP submit [--lang {language}] Submit the current solution to the online judge. Uses stored credentials (set via - :CP credentials set). Prompts on first use + :CP credentials login). Prompts on first use if no credentials are saved. --lang: Submit solution for a specific language. @@ -714,7 +716,7 @@ Example: Setting up and solving AtCoder contest ABC324 8. Submit solution: > :CP submit -< Prompts for credentials on first use and submits to AtCoder. +< Uses stored credentials and submits to AtCoder. ============================================================================== I/O VIEW *cp-io-view* @@ -968,13 +970,17 @@ Manage stored login credentials for platform submission. Credentials are stored under _credentials in the main cache file (stdpath('data')/cp-nvim.json). Use :CP cache read to inspect them. -:CP credentials set [platform] +:CP credentials login [platform] Set or update credentials for a platform. Always prompts for username and password, overwriting any previously saved values. Omit [platform] to use the currently active platform. -:CP credentials clear [platform] - Remove stored credentials. Without [platform], clears all platforms. +:CP credentials logout [platform] + Remove stored credentials for a platform. + Omit [platform] to use the currently active platform. + +:CP credentials clear + Remove stored credentials for all platforms. ============================================================================== SUBMIT *cp-submit* @@ -983,7 +989,7 @@ Submit the current solution to the online judge. :CP submit [--lang {language}] Submit the current solution. Uses stored credentials (set via - :CP credentials set). Prompts on first use if no credentials + :CP credentials login). Prompts on first use if no credentials are saved. --lang: Override the language to submit. diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 2a6be0a..96a3db3 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -86,14 +86,22 @@ local function parse_command(args) elseif first == 'credentials' then local subcommand = args[2] if not subcommand then - return { type = 'error', message = 'credentials command requires subcommand (set, clear)' } + return { + type = 'error', + message = 'credentials command requires subcommand (login, logout, clear)', + } end - if vim.tbl_contains({ 'set', 'clear' }, subcommand) then + if vim.tbl_contains({ 'login', 'logout' }, subcommand) then return { type = 'credentials', subcommand = subcommand, platform = args[3], } + elseif subcommand == 'clear' then + return { + type = 'credentials', + subcommand = 'clear', + } else return { type = 'error', message = 'unknown credentials subcommand: ' .. subcommand } end @@ -368,10 +376,12 @@ function M.handle_command(opts) setup.setup_contest(platform, contest_id, problem_id, cmd.language) elseif cmd.type == 'credentials' then local creds = require('cp.credentials') - if cmd.subcommand == 'set' then - creds.set(cmd.platform) + if cmd.subcommand == 'login' then + creds.login(cmd.platform) + elseif cmd.subcommand == 'logout' then + creds.logout(cmd.platform) elseif cmd.subcommand == 'clear' then - creds.clear(cmd.platform) + creds.clear() end elseif cmd.type == 'cache' then local cache_commands = require('cp.commands.cache') diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index c7d0f49..3054344 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -4,10 +4,13 @@ local cache = require('cp.cache') local logger = require('cp.log') local state = require('cp.state') -function M.set(platform) +function M.login(platform) platform = platform or state.get_platform() if not platform then - logger.log('No platform specified. Usage: :CP credentials set ', vim.log.levels.ERROR) + logger.log( + 'No platform specified. Usage: :CP credentials login ', + vim.log.levels.ERROR + ) return end @@ -29,14 +32,24 @@ function M.set(platform) end) end -function M.clear(platform) +function M.logout(platform) + platform = platform or state.get_platform() + if not platform then + logger.log( + 'No platform specified. Usage: :CP credentials logout ', + vim.log.levels.ERROR + ) + return + end cache.load() cache.clear_credentials(platform) - if platform then - logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true) - else - logger.log('all credentials cleared', vim.log.levels.INFO, true) - end + logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true) +end + +function M.clear() + cache.load() + cache.clear_credentials(nil) + logger.log('all credentials cleared', vim.log.levels.INFO, true) end return M diff --git a/plugin/cp.lua b/plugin/cp.lua index 41e474f..a1d3c05 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -104,7 +104,7 @@ end, { end return filter_candidates(candidates) elseif args[2] == 'credentials' then - return filter_candidates({ 'set', 'clear' }) + return filter_candidates({ 'login', 'logout', 'clear' }) elseif args[2] == 'race' then local candidates = { 'stop' } vim.list_extend(candidates, platforms) @@ -126,7 +126,7 @@ end, { cache.load() local contests = cache.get_cached_contest_ids(args[3]) return filter_candidates(contests) - elseif args[2] == 'credentials' and vim.tbl_contains({ 'set', 'clear' }, args[3]) then + elseif args[2] == 'credentials' and vim.tbl_contains({ 'login', 'logout' }, args[3]) then return filter_candidates(platforms) elseif args[2] == 'cache' and args[3] == 'clear' then local candidates = vim.list_extend({}, platforms) From 49e0ae388578059283be7e285da2a6b49d64f3f0 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:09:32 -0500 Subject: [PATCH 073/114] refactor(credentials): promote login/logout to top-level actions (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `:CP credentials login/logout/clear` is verbose and inconsistent with other actions that are all top-level (`:CP run`, `:CP submit`, etc.). The clear-all subcommand is also unnecessary since re-logging in overwrites existing credentials. ## Solution Replace `:CP credentials {login,logout,clear}` with `:CP login [platform]` and `:CP logout [platform]`. Remove the clear-all command and the credentials subcommand dispatch — login/logout are now regular actions routed through the standard action dispatcher. --- doc/cp.nvim.txt | 39 ++++++++++++++++----------------------- lua/cp/commands/init.lua | 37 ++++++------------------------------- lua/cp/constants.lua | 3 ++- lua/cp/credentials.lua | 16 ++-------------- plugin/cp.lua | 6 ++---- 5 files changed, 28 insertions(+), 73 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 31b9d8a..f61d39e 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -433,30 +433,27 @@ COMMANDS *cp-commands* Cancel an active race countdown. Credential Commands ~ - :CP credentials login [platform] + :CP login [platform] Set or update stored credentials for a platform. - Always prompts for username and password, - overwriting any previously saved credentials. + Prompts for username and password, overwriting + any previously saved credentials. If [platform] is omitted, uses the active platform. Examples: > - :CP credentials login atcoder - :CP credentials login codeforces + :CP login atcoder + :CP login codeforces < - :CP credentials logout [platform] + :CP logout [platform] Remove stored credentials for a platform. If [platform] is omitted, uses the active platform. Examples: > - :CP credentials logout atcoder -< - :CP credentials clear - Remove stored credentials for all platforms. + :CP logout atcoder < Submit Commands ~ :CP submit [--lang {language}] Submit the current solution to the online judge. Uses stored credentials (set via - :CP credentials login). Prompts on first use - if no credentials are saved. + :CP login). Prompts on first use if no + credentials are saved. --lang: Submit solution for a specific language. State Restoration ~ @@ -963,34 +960,30 @@ Count down to a contest's start time and automatically run setup at T=0. Statusline integration: see |cp-race-status|. ============================================================================== -CREDENTIALS *cp-credentials* +CREDENTIALS *cp-credentials* Manage stored login credentials for platform submission. Credentials are stored under _credentials in the main cache file (stdpath('data')/cp-nvim.json). Use :CP cache read to inspect them. -:CP credentials login [platform] - Set or update credentials for a platform. Always prompts for - username and password, overwriting any previously saved values. +:CP login [platform] + Set or update credentials for a platform. Prompts for username + and password, overwriting any previously saved values. Omit [platform] to use the currently active platform. -:CP credentials logout [platform] +:CP logout [platform] Remove stored credentials for a platform. Omit [platform] to use the currently active platform. -:CP credentials clear - Remove stored credentials for all platforms. - ============================================================================== -SUBMIT *cp-submit* +SUBMIT *cp-submit* Submit the current solution to the online judge. :CP submit [--lang {language}] Submit the current solution. Uses stored credentials (set via - :CP credentials login). Prompts on first use if no credentials - are saved. + :CP login). Prompts on first use if no credentials are saved. --lang: Override the language to submit. Platform support: diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 96a3db3..56a473f 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -83,28 +83,8 @@ local function parse_command(args) else return { type = 'action', action = 'interact' } end - elseif first == 'credentials' then - local subcommand = args[2] - if not subcommand then - return { - type = 'error', - message = 'credentials command requires subcommand (login, logout, clear)', - } - end - if vim.tbl_contains({ 'login', 'logout' }, subcommand) then - return { - type = 'credentials', - subcommand = subcommand, - platform = args[3], - } - elseif subcommand == 'clear' then - return { - type = 'credentials', - subcommand = 'clear', - } - else - return { type = 'error', message = 'unknown credentials subcommand: ' .. subcommand } - end + elseif first == 'login' or first == 'logout' then + return { type = 'action', action = first, platform = args[2] } elseif first == 'stress' then return { type = 'action', @@ -345,6 +325,10 @@ function M.handle_command(opts) require('cp.race').start(cmd.platform, cmd.contest, cmd.language) elseif cmd.action == 'race_stop' then require('cp.race').stop() + elseif cmd.action == 'login' then + require('cp.credentials').login(cmd.platform) + elseif cmd.action == 'logout' then + require('cp.credentials').logout(cmd.platform) end elseif cmd.type == 'problem_jump' then local platform = state.get_platform() @@ -374,15 +358,6 @@ function M.handle_command(opts) local setup = require('cp.setup') setup.setup_contest(platform, contest_id, problem_id, cmd.language) - elseif cmd.type == 'credentials' then - local creds = require('cp.credentials') - if cmd.subcommand == 'login' then - creds.login(cmd.platform) - elseif cmd.subcommand == 'logout' then - creds.logout(cmd.platform) - elseif cmd.subcommand == 'clear' then - creds.clear() - end elseif cmd.type == 'cache' then local cache_commands = require('cp.commands.cache') cache_commands.handle_cache_command(cmd) diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index c1add6a..5d1c3c5 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -13,7 +13,8 @@ M.ACTIONS = { 'race', 'stress', 'submit', - 'credentials', + 'login', + 'logout', } M.PLATFORM_DISPLAY_NAMES = { diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index 3054344..8f80743 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -7,10 +7,7 @@ local state = require('cp.state') function M.login(platform) platform = platform or state.get_platform() if not platform then - logger.log( - 'No platform specified. Usage: :CP credentials login ', - vim.log.levels.ERROR - ) + logger.log('No platform specified. Usage: :CP login ', vim.log.levels.ERROR) return end @@ -35,10 +32,7 @@ end function M.logout(platform) platform = platform or state.get_platform() if not platform then - logger.log( - 'No platform specified. Usage: :CP credentials logout ', - vim.log.levels.ERROR - ) + logger.log('No platform specified. Usage: :CP logout ', vim.log.levels.ERROR) return end cache.load() @@ -46,10 +40,4 @@ function M.logout(platform) logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true) end -function M.clear() - cache.load() - cache.clear_credentials(nil) - logger.log('all credentials cleared', vim.log.levels.INFO, true) -end - return M diff --git a/plugin/cp.lua b/plugin/cp.lua index a1d3c05..db9a8bd 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -103,8 +103,8 @@ end, { end end return filter_candidates(candidates) - elseif args[2] == 'credentials' then - return filter_candidates({ 'login', 'logout', 'clear' }) + elseif args[2] == 'login' or args[2] == 'logout' then + return filter_candidates(platforms) elseif args[2] == 'race' then local candidates = { 'stop' } vim.list_extend(candidates, platforms) @@ -126,8 +126,6 @@ end, { cache.load() local contests = cache.get_cached_contest_ids(args[3]) return filter_candidates(contests) - elseif args[2] == 'credentials' and vim.tbl_contains({ 'login', 'logout' }, args[3]) then - return filter_candidates(platforms) elseif args[2] == 'cache' and args[3] == 'clear' then local candidates = vim.list_extend({}, platforms) table.insert(candidates, '') From 1bc0aa41b673801e7b8e0fd2e31905ecc787c500 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:37:22 -0500 Subject: [PATCH 074/114] refactor(cache): nest credentials under platform namespace (#293) ## Problem Credentials lived in a top-level `_credentials` namespace, requiring special preservation logic in `clear_all()` and a separate key hierarchy from the platform data they belong to. ## Solution Move credentials from `_credentials.` to `._credentials`. Migrate v1 caches on load, skip underscore-prefixed keys when enumerating contest IDs and summaries, and simplify `clear_all()` now that no special preservation is needed. Stacked on #292. --- lua/cp/cache.lua | 66 ++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index baea9b9..eb82afa 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -40,7 +40,7 @@ local M = {} -local CACHE_VERSION = 1 +local CACHE_VERSION = 2 local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_data = {} @@ -67,13 +67,27 @@ function M.load() end local ok, decoded = pcall(vim.json.decode, table.concat(content, '\n')) - if ok then - if decoded._version ~= CACHE_VERSION then - cache_data = {} - M.save() - else - cache_data = decoded + if not ok then + cache_data = {} + M.save() + loaded = true + return + end + + if decoded._version == 1 then + local old_creds = decoded._credentials + decoded._credentials = nil + if old_creds then + for platform, creds in pairs(old_creds) do + decoded[platform] = decoded[platform] or {} + decoded[platform]._credentials = creds + end end + decoded._version = CACHE_VERSION + cache_data = decoded + M.save() + elseif decoded._version == CACHE_VERSION then + cache_data = decoded else cache_data = {} M.save() @@ -122,7 +136,9 @@ function M.get_cached_contest_ids(platform) local contest_ids = {} for contest_id, _ in pairs(cache_data[platform]) do - table.insert(contest_ids, contest_id) + if contest_id:sub(1, 1) ~= '_' then + table.insert(contest_ids, contest_id) + end end table.sort(contest_ids) return contest_ids @@ -336,11 +352,13 @@ end function M.get_contest_summaries(platform) local contest_list = {} for contest_id, contest_data in pairs(cache_data[platform] or {}) do - table.insert(contest_list, { - id = contest_id, - name = contest_data.name, - display_name = contest_data.display_name, - }) + if contest_id:sub(1, 1) ~= '_' then + table.insert(contest_list, { + id = contest_id, + name = contest_data.name, + display_name = contest_data.display_name, + }) + end end return contest_list end @@ -374,38 +392,30 @@ end ---@param platform string ---@return table? function M.get_credentials(platform) - if not cache_data._credentials then + if not cache_data[platform] then return nil end - return cache_data._credentials[platform] + return cache_data[platform]._credentials end ---@param platform string ---@param creds table function M.set_credentials(platform, creds) - cache_data._credentials = cache_data._credentials or {} - cache_data._credentials[platform] = creds + cache_data[platform] = cache_data[platform] or {} + cache_data[platform]._credentials = creds M.save() end ----@param platform string? +---@param platform string function M.clear_credentials(platform) - if platform then - if cache_data._credentials then - cache_data._credentials[platform] = nil - end - else - cache_data._credentials = nil + if cache_data[platform] then + cache_data[platform]._credentials = nil end M.save() end function M.clear_all() - local creds = cache_data._credentials cache_data = {} - if creds then - cache_data._credentials = creds - end M.save() end From c194f12eeeef673920670928cbb2e32f46524c3a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:27:29 -0500 Subject: [PATCH 075/114] feat(atcoder): extract submit helpers; add live status notifications (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `_submit_sync` was a 170-line nested closure with `_solve_turnstile` and the browser-install block further nested inside it. Status events went to stderr, which `run_scraper()` silently discards, leaving the user with a 10–30s silent hang after credential entry. The NDJSON spawn path also lacked stdin support, so submit had no streaming path at all. ## Solution Extract `_TURNSTILE_JS`, `_solve_turnstile`, `_ensure_browser`, and `_submit_headless` to module level in `atcoder.py`; status events (`installing_browser`, `checking_login`, `logging_in`, `submitting`) now print to stdout as NDJSON. Add stdin pipe support to the NDJSON spawn path in `scraper.lua` and switch `M.submit` to streaming with an `on_status` callback. Wire `on_status` in `submit.lua` to fire `vim.notify` for each phase transition. --- .luarc.json | 9 +- .styluaignore | 1 + flake.nix | 67 +++++- lua/cp/scraper.lua | 81 +++++-- lua/cp/submit.lua | 14 ++ lua/cp/utils.lua | 77 +++++++ pyproject.toml | 1 + scrapers/atcoder.py | 271 ++++++++++++++++-------- scrapers/language_ids.py | 4 +- scripts/ci.sh | 13 ++ uv.lock | 441 +++++++++++++++++++++++++++++++++++++-- 11 files changed, 863 insertions(+), 116 deletions(-) create mode 100644 .styluaignore create mode 100755 scripts/ci.sh diff --git a/.luarc.json b/.luarc.json index e0f7a7c..727793d 100644 --- a/.luarc.json +++ b/.luarc.json @@ -7,8 +7,13 @@ "globals": ["vim"] }, "workspace": { - "library": ["$VIMRUNTIME/lua", "${3rd}/luv/library", "${3rd}/busted/library"], - "checkThirdParty": false + "library": [ + "$VIMRUNTIME/lua", + "${3rd}/luv/library", + "${3rd}/busted/library" + ], + "checkThirdParty": false, + "ignoreDir": [".direnv"] }, "completion": { "callSnippet": "Replace" diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..9b42106 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +.direnv/ diff --git a/flake.nix b/flake.nix index 202b13b..1c416c6 100644 --- a/flake.nix +++ b/flake.nix @@ -26,10 +26,65 @@ ps.requests ]); + mkDevPythonEnv = + pkgs: + pkgs.python312.withPackages (ps: [ + ps.backoff + ps.beautifulsoup4 + ps.curl-cffi + ps.httpx + ps.ndjson + ps.pydantic + ps.requests + ps.pytest + ps.pytest-mock + ]); + + mkSubmitEnv = + pkgs: + pkgs.buildFHSEnv { + name = "cp-nvim-submit"; + targetPkgs = + pkgs: with pkgs; [ + uv + alsa-lib + at-spi2-atk + cairo + cups + dbus + fontconfig + freetype + gdk-pixbuf + glib + gtk3 + libdrm + libxkbcommon + mesa + libGL + nspr + nss + pango + libx11 + libxcomposite + libxdamage + libxext + libxfixes + libxrandr + libxcb + at-spi2-core + expat + libgbm + systemdLibs + zlib + ]; + runScript = "${pkgs.uv}/bin/uv"; + }; + mkPlugin = pkgs: let pythonEnv = mkPythonEnv pkgs; + submitEnv = mkSubmitEnv pkgs; in pkgs.vimUtils.buildVimPlugin { pname = "cp-nvim"; @@ -39,12 +94,15 @@ substituteInPlace lua/cp/utils.lua \ --replace-fail "local _nix_python = nil" \ "local _nix_python = '${pythonEnv.interpreter}'" + substituteInPlace lua/cp/utils.lua \ + --replace-fail "local _nix_submit_cmd = nil" \ + "local _nix_submit_cmd = '${submitEnv}/bin/cp-nvim-submit'" ''; nvimSkipModule = [ "cp.pickers.telescope" "cp.version" ]; - passthru = { inherit pythonEnv; }; + passthru = { inherit pythonEnv submitEnv; }; meta.description = "Competitive programming plugin for Neovim"; }; in @@ -58,17 +116,22 @@ packages = eachSystem (system: { default = mkPlugin (pkgsFor system); pythonEnv = mkPythonEnv (pkgsFor system); + submitEnv = mkSubmitEnv (pkgsFor system); }); + formatter = eachSystem (system: (pkgsFor system).nixfmt-tree); + devShells = eachSystem (system: { default = (pkgsFor system).mkShell { packages = with (pkgsFor system); [ uv - python312 + (mkDevPythonEnv (pkgsFor system)) prettier + ruff stylua selene lua-language-server + ty ]; }; }); diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 7f774c3..7ddca0a 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -44,8 +44,17 @@ local function run_scraper(platform, subcommand, args, opts) return { success = false, error = msg } end + if subcommand == 'submit' then + utils.setup_nix_submit_env() + end + local plugin_path = utils.get_plugin_path() - local cmd = utils.get_python_cmd(platform, plugin_path) + local cmd + if subcommand == 'submit' then + cmd = utils.get_python_submit_cmd(platform, plugin_path) + else + cmd = utils.get_python_cmd(platform, plugin_path) + end vim.list_extend(cmd, { subcommand }) vim.list_extend(cmd, args) @@ -62,8 +71,16 @@ local function run_scraper(platform, subcommand, args, opts) end end + if subcommand == 'submit' and utils.is_nix_build() then + env.UV_PROJECT_ENVIRONMENT = vim.fn.stdpath('cache') .. '/cp-nvim/submit-env' + end + if opts and opts.ndjson then local uv = vim.uv + local stdin_pipe = nil + if opts.stdin then + stdin_pipe = uv.new_pipe(false) + end local stdout = uv.new_pipe(false) local stderr = uv.new_pipe(false) local buf = '' @@ -71,7 +88,7 @@ local function run_scraper(platform, subcommand, args, opts) local handle handle = uv.spawn(cmd[1], { args = vim.list_slice(cmd, 2), - stdio = { nil, stdout, stderr }, + stdio = { stdin_pipe, stdout, stderr }, env = spawn_env_list(env), cwd = plugin_path, }, function(code, signal) @@ -85,6 +102,9 @@ local function run_scraper(platform, subcommand, args, opts) if opts.on_exit then opts.on_exit({ success = (code == 0), code = code, signal = signal }) end + if stdin_pipe and not stdin_pipe:is_closing() then + stdin_pipe:close() + end if not stdout:is_closing() then stdout:close() end @@ -97,10 +117,21 @@ local function run_scraper(platform, subcommand, args, opts) end) if not handle then + if stdin_pipe and not stdin_pipe:is_closing() then + stdin_pipe:close() + end logger.log('Failed to start scraper process', vim.log.levels.ERROR) return { success = false, error = 'spawn failed' } end + if stdin_pipe then + uv.write(stdin_pipe, opts.stdin, function() + uv.shutdown(stdin_pipe, function() + stdin_pipe:close() + end) + end) + end + uv.read_start(stdout, function(_, data) if data == nil then if buf ~= '' and opts.on_event then @@ -131,7 +162,12 @@ local function run_scraper(platform, subcommand, args, opts) return end - local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path } + local sysopts = { + text = true, + timeout = (subcommand == 'submit') and 120000 or 30000, + env = env, + cwd = plugin_path, + } if opts and opts.stdin then sysopts.stdin = opts.stdin end @@ -246,18 +282,39 @@ function M.scrape_all_tests(platform, contest_id, callback) }) end -function M.submit(platform, contest_id, problem_id, language, source_code, credentials, callback) - local creds_json = vim.json.encode(credentials) +function M.submit( + platform, + contest_id, + problem_id, + language, + source_code, + credentials, + on_status, + callback +) + local done = false run_scraper(platform, 'submit', { contest_id, problem_id, language }, { + ndjson = true, stdin = source_code, - env_extra = { CP_CREDENTIALS = creds_json }, - on_exit = function(result) - if type(callback) == 'function' then - if result and result.success then - callback(result.data or { success = true }) - else - callback({ success = false, error = result and result.error or 'unknown' }) + env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) }, + on_event = function(ev) + if ev.status ~= nil then + if type(on_status) == 'function' then + on_status(ev.status) end + elseif ev.success ~= nil then + done = true + if type(callback) == 'function' then + callback(ev) + end + end + end, + on_exit = function(proc) + if not done and type(callback) == 'function' then + callback({ + success = false, + error = 'submit process exited (code=' .. tostring(proc.code) .. ')', + }) end end, }) diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index d7f91fa..4efe25e 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -4,6 +4,13 @@ local cache = require('cp.cache') local logger = require('cp.log') local state = require('cp.state') +local STATUS_MSGS = { + installing_browser = 'Installing browser (first time setup)...', + checking_login = 'Checking login...', + logging_in = 'Logging in...', + submitting = 'Submitting...', +} + local function prompt_credentials(platform, callback) local saved = cache.get_credentials(platform) if saved and saved.username and saved.password then @@ -48,6 +55,8 @@ function M.submit(opts) local source_lines = vim.fn.readfile(source_file) local source_code = table.concat(source_lines, '\n') + vim.notify('[cp.nvim] Submitting...', vim.log.levels.INFO) + require('cp.scraper').submit( platform, contest_id, @@ -55,6 +64,11 @@ function M.submit(opts) language, source_code, creds, + function(status) + vim.schedule(function() + vim.notify('[cp.nvim] ' .. (STATUS_MSGS[status] or status), vim.log.levels.INFO) + end) + end, function(result) vim.schedule(function() if result and result.success then diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index d300ee7..3111fec 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -3,6 +3,7 @@ local M = {} local logger = require('cp.log') local _nix_python = nil +local _nix_submit_cmd = nil local _nix_discovered = false local uname = vim.uv.os_uname() @@ -111,7 +112,83 @@ function M.get_python_cmd(module, plugin_path) return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module } end +---@param module string +---@param plugin_path string +---@return string[] +function M.get_python_submit_cmd(module, plugin_path) + if _nix_submit_cmd then + return { _nix_submit_cmd, 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module } + end + return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module } +end + local python_env_setup = false +local _nix_submit_attempted = false + +---@return boolean +local function discover_nix_submit_cmd() + local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim' + local cache_file = cache_dir .. '/nix-submit' + + local f = io.open(cache_file, 'r') + if f then + local cached = f:read('*l') + f:close() + if cached and vim.fn.executable(cached) == 1 then + _nix_submit_cmd = cached + return true + end + end + + local plugin_path = M.get_plugin_path() + vim.cmd.redraw() + vim.notify('Building submit environment...', vim.log.levels.INFO) + vim.cmd.redraw() + local result = vim + .system( + { 'nix', 'build', plugin_path .. '#submitEnv', '--no-link', '--print-out-paths' }, + { text = true } + ) + :wait() + + if result.code ~= 0 then + logger.log('nix build #submitEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN) + return false + end + + local store_path = result.stdout:gsub('%s+$', '') + local submit_cmd = store_path .. '/bin/cp-nvim-submit' + + if vim.fn.executable(submit_cmd) ~= 1 then + logger.log('nix submit cmd not executable at ' .. submit_cmd, vim.log.levels.WARN) + return false + end + + vim.fn.mkdir(cache_dir, 'p') + f = io.open(cache_file, 'w') + if f then + f:write(submit_cmd) + f:close() + end + + _nix_submit_cmd = submit_cmd + return true +end + +---@return boolean +function M.setup_nix_submit_env() + if _nix_submit_cmd then + return true + end + if _nix_submit_attempted then + return false + end + _nix_submit_attempted = true + if vim.fn.executable('nix') == 1 then + return discover_nix_submit_cmd() + end + return false +end ---@return boolean local function discover_nix_python() diff --git a/pyproject.toml b/pyproject.toml index b18e7aa..9ffc00c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.11" dependencies = [ "backoff>=2.2.1", "beautifulsoup4>=4.13.5", + "scrapling[fetchers]>=0.4", "curl-cffi>=0.13.0", "httpx>=0.28.1", "ndjson>=0.3.1", diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index f7124ed..8be75ff 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -4,7 +4,9 @@ import asyncio import json import os import re +import subprocess import sys +import tempfile import time from typing import Any @@ -233,6 +235,183 @@ def _extract_samples(html: str) -> list[TestCase]: return cases +_TURNSTILE_JS = "() => { const el = document.querySelector('[name=\"cf-turnstile-response\"]'); return el && el.value.length > 0; }" + + +def _solve_turnstile(page) -> None: + for _ in range(6): + has_token = page.evaluate(_TURNSTILE_JS) + if has_token: + return + try: + box = page.locator( + 'iframe[src*="challenges.cloudflare.com"]' + ).first.bounding_box() + if box: + page.mouse.click( + box["x"] + box["width"] * 0.15, + box["y"] + box["height"] * 0.5, + ) + except Exception: + pass + try: + page.wait_for_function(_TURNSTILE_JS, timeout=5000) + return + except Exception: + pass + raise RuntimeError("Turnstile not solved after multiple attempts") + + +def _ensure_browser() -> None: + try: + from patchright._impl._driver import compute_driver_executable # type: ignore[import-untyped,unresolved-import] + + node, cli = compute_driver_executable() + browser_info = subprocess.run( + [node, cli, "install", "--dry-run", "chromium"], + capture_output=True, + text=True, + ) + for line in browser_info.stdout.splitlines(): + if "Install location:" in line: + install_dir = line.split(":", 1)[1].strip() + if not os.path.isdir(install_dir): + print(json.dumps({"status": "installing_browser"}), flush=True) + subprocess.run( + [node, cli, "install", "chromium"], + check=True, + ) + break + except Exception: + pass + + +def _submit_headless( + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], +) -> "SubmitResult": + from pathlib import Path + + try: + from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] + except ImportError: + return SubmitResult( + success=False, + error="scrapling is required for AtCoder submit. Install it: uv add 'scrapling[fetchers]>=0.4'", + ) + + _ensure_browser() + + cookie_cache = Path.home() / ".cache" / "cp-nvim" / "atcoder-cookies.json" + cookie_cache.parent.mkdir(parents=True, exist_ok=True) + saved_cookies: list[dict[str, Any]] = [] + if cookie_cache.exists(): + try: + saved_cookies = json.loads(cookie_cache.read_text()) + except Exception: + pass + + logged_in = False + login_error: str | None = None + submit_error: str | None = None + + def check_login(page): + nonlocal logged_in + logged_in = page.evaluate( + "() => Array.from(document.querySelectorAll('a')).some(a => a.textContent.trim() === 'Sign Out')" + ) + + def login_action(page): + nonlocal login_error + try: + _solve_turnstile(page) + page.fill('input[name="username"]', credentials.get("username", "")) + page.fill('input[name="password"]', credentials.get("password", "")) + page.click("#submit") + page.wait_for_url(lambda url: "/login" not in url, timeout=60000) + except Exception as e: + login_error = str(e) + + def submit_action(page): + nonlocal submit_error + try: + _solve_turnstile(page) + page.select_option( + 'select[name="data.TaskScreenName"]', + f"{contest_id}_{problem_id}", + ) + page.locator( + f'select[name="data.LanguageId"] option[value="{language_id}"]' + ).wait_for(state="attached", timeout=15000) + page.select_option('select[name="data.LanguageId"]', language_id) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".cpp", delete=False, prefix="atcoder_" + ) as tf: + tf.write(source_code) + tmp_path = tf.name + try: + page.set_input_files("#input-open-file", tmp_path) + page.wait_for_timeout(500) + finally: + os.unlink(tmp_path) + page.locator('button[type="submit"]').click() + page.wait_for_url(lambda url: "/submissions/me" in url, timeout=60000) + except Exception as e: + submit_error = str(e) + + try: + with StealthySession( + headless=True, + timeout=60000, + google_search=False, + cookies=saved_cookies, + ) as session: + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/home", + page_action=check_login, + network_idle=True, + ) + + if not logged_in: + print(json.dumps({"status": "logging_in"}), flush=True) + session.fetch( + f"{BASE_URL}/login", + page_action=login_action, + solve_cloudflare=True, + ) + if login_error: + return SubmitResult( + success=False, error=f"Login failed: {login_error}" + ) + + print(json.dumps({"status": "submitting"}), flush=True) + session.fetch( + f"{BASE_URL}/contests/{contest_id}/submit", + page_action=submit_action, + solve_cloudflare=True, + ) + + try: + browser_cookies = session.context.cookies() + if any(c["name"] == "REVEL_SESSION" for c in browser_cookies): + cookie_cache.write_text(json.dumps(browser_cookies)) + except Exception: + pass + + if submit_error: + return SubmitResult(success=False, error=submit_error) + + return SubmitResult( + success=True, error="", submission_id="", verdict="submitted" + ) + except Exception as e: + return SubmitResult(success=False, error=str(e)) + + def _scrape_tasks_sync(contest_id: str) -> list[dict[str, str]]: html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks") return _parse_tasks_list(html) @@ -379,90 +558,14 @@ class AtcoderScraper(BaseScraper): language_id: str, credentials: dict[str, str], ) -> SubmitResult: - def _submit_sync() -> SubmitResult: - from curl_cffi import requests as curl_requests - - try: - session = curl_requests.Session(impersonate="chrome") - - login_page = session.get(f"{BASE_URL}/login", timeout=TIMEOUT_SECONDS) - login_page.raise_for_status() - soup = BeautifulSoup(login_page.text, "html.parser") - csrf_input = soup.find("input", {"name": "csrf_token"}) - if not csrf_input or not hasattr(csrf_input, "get"): - return SubmitResult( - success=False, error="Could not find CSRF token on login page" - ) - csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr] - - login_resp = session.post( - f"{BASE_URL}/login", - data={ - "username": credentials.get("username", ""), - "password": credentials.get("password", ""), - "csrf_token": csrf_token, - }, - timeout=TIMEOUT_SECONDS, - allow_redirects=False, - ) - if login_resp.status_code in (301, 302): - location = login_resp.headers.get("Location", "") - if "/login" in location: - return SubmitResult( - success=False, - error="Login failed: incorrect username or password", - ) - session.get(BASE_URL + location, timeout=TIMEOUT_SECONDS) - else: - login_resp.raise_for_status() - - submit_page = session.get( - f"{BASE_URL}/contests/{contest_id}/submit", - timeout=TIMEOUT_SECONDS, - ) - submit_page.raise_for_status() - soup = BeautifulSoup(submit_page.text, "html.parser") - csrf_input = soup.find("input", {"name": "csrf_token"}) - if not csrf_input or not hasattr(csrf_input, "get"): - return SubmitResult( - success=False, error="Could not find CSRF token on submit page" - ) - csrf_token = csrf_input.get("value", "") or "" # type: ignore[union-attr] - - task_screen_name = f"{contest_id}_{problem_id}" - submit_resp = session.post( - f"{BASE_URL}/contests/{contest_id}/submit", - data={ - "data.TaskScreenName": task_screen_name, - "data.LanguageId": language_id, - "sourceCode": source_code, - "csrf_token": csrf_token, - }, - timeout=TIMEOUT_SECONDS, - allow_redirects=False, - ) - if submit_resp.status_code in (301, 302): - location = submit_resp.headers.get("Location", "") - if "/submissions/me" in location: - return SubmitResult( - success=True, - error="", - submission_id="", - verdict="submitted", - ) - return SubmitResult( - success=False, - error=f"Submit may have failed: redirected to {location}", - ) - submit_resp.raise_for_status() - return SubmitResult( - success=False, - error="Unexpected response from submit (expected redirect)", - ) - except Exception as e: - return SubmitResult(success=False, error=str(e)) - - return await asyncio.to_thread(_submit_sync) + return await asyncio.to_thread( + _submit_headless, + contest_id, + problem_id, + source_code, + language_id, + credentials, + ) async def main_async() -> int: diff --git a/scrapers/language_ids.py b/scrapers/language_ids.py index d0a47e6..d6a0ae4 100644 --- a/scrapers/language_ids.py +++ b/scrapers/language_ids.py @@ -1,7 +1,7 @@ LANGUAGE_IDS = { "atcoder": { - "cpp": "5028", - "python": "5078", + "cpp": "6017", + "python": "6082", }, "codeforces": { "cpp": "89", diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..03f7da1 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check . --checklevel=Warning +nix develop --command ruff format --check . +nix develop --command ruff check . +nix develop --command ty check . +nix develop --command python -m pytest tests/ -v diff --git a/uv.lock b/uv.lock index 66bd81e..d3334ab 100644 --- a/uv.lock +++ b/uv.lock @@ -13,15 +13,24 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.0" +version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "apify-fingerprint-datapoints" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/a9/586b7ebdd682c047cd0b551dc7e154bb1480f8f6548154708e9a6c7844db/apify_fingerprint_datapoints-0.11.0.tar.gz", hash = "sha256:3f905c392b11a27fb59ccfe40891c166abd737ab9c6209733f102bbb3b302515", size = 969830, upload-time = "2026-03-01T01:00:04.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/38/9483eb52fc0f00039c684af627f8a8f994a8a99e8eceb869ba93b3fd740b/apify_fingerprint_datapoints-0.11.0-py3-none-any.whl", hash = "sha256:333340ccc3e520f19b5561e95d7abe2b31702e61d34b6247b328c9b8c93fbe1d", size = 726498, upload-time = "2026-03-01T01:00:03.103Z" }, ] [[package]] @@ -58,6 +67,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "browserforge" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apify-fingerprint-datapoints" }, + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/6f/8975af88d203efd70cc69477ebac702babef38201d04621c9583f2508f25/browserforge-1.2.4.tar.gz", hash = "sha256:05686473793769856ebd3528c69071f5be0e511260993e8b2ba839863711a0c4", size = 36700, upload-time = "2026-02-03T02:52:09.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/35/ce962f738ae28ffce6293e7607b129075633e6bb185a5ab87e49246eedc2/browserforge-1.2.4-py3-none-any.whl", hash = "sha256:fb1c14e62ac09de221dcfc73074200269f697596c642cb200ceaab1127a17542", size = 37890, upload-time = "2026-02-03T02:52:08.745Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -219,6 +241,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -228,25 +262,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cssselect" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, +] + [[package]] name = "curl-cffi" -version = "0.13.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/c9/0067d9a25ed4592b022d4558157fcdb6e123516083700786d38091688767/curl_cffi-0.14.0.tar.gz", hash = "sha256:5ffbc82e59f05008ec08ea432f0e535418823cda44178ee518906a54f27a5f0f", size = 162633, upload-time = "2025-12-16T03:25:07.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, - { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, - { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, - { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, - { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/0f21e9688eaac85e705537b3a87a5588d0cefb2f09d83e83e0e8be93aa99/curl_cffi-0.14.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:e35e89c6a69872f9749d6d5fda642ed4fc159619329e99d577d0104c9aad5893", size = 3087277, upload-time = "2025-12-16T03:24:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a3/0419bd48fce5b145cb6a2344c6ac17efa588f5b0061f212c88e0723da026/curl_cffi-0.14.0-cp39-abi3-macosx_15_0_x86_64.whl", hash = "sha256:5945478cd28ad7dfb5c54473bcfb6743ee1d66554d57951fdf8fc0e7d8cf4e45", size = 5804650, upload-time = "2025-12-16T03:24:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/e2/07/a238dd062b7841b8caa2fa8a359eb997147ff3161288f0dd46654d898b4d/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c42e8fa3c667db9ccd2e696ee47adcd3cd5b0838d7282f3fc45f6c0ef3cfdfa7", size = 8231918, upload-time = "2025-12-16T03:24:52.862Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/ce907c9b37b5caf76ac08db40cc4ce3d9f94c5500db68a195af3513eacbc/curl_cffi-0.14.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:060fe2c99c41d3cb7f894de318ddf4b0301b08dca70453d769bd4e74b36b8483", size = 8654624, upload-time = "2025-12-16T03:24:54.579Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ae/6256995b18c75e6ef76b30753a5109e786813aa79088b27c8eabb1ef85c9/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b158c41a25388690dd0d40b5bc38d1e0f512135f17fdb8029868cbc1993d2e5b", size = 8010654, upload-time = "2025-12-16T03:24:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/fb/10/ff64249e516b103cb762e0a9dca3ee0f04cf25e2a1d5d9838e0f1273d071/curl_cffi-0.14.0-cp39-abi3-manylinux_2_28_i686.whl", hash = "sha256:1439fbef3500fb723333c826adf0efb0e2e5065a703fb5eccce637a2250db34a", size = 7781969, upload-time = "2025-12-16T03:24:57.885Z" }, + { url = "https://files.pythonhosted.org/packages/51/76/d6f7bb76c2d12811aa7ff16f5e17b678abdd1b357b9a8ac56310ceccabd5/curl_cffi-0.14.0-cp39-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7176f2c2d22b542e3cf261072a81deb018cfa7688930f95dddef215caddb469", size = 7969133, upload-time = "2025-12-16T03:24:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/23/7c/cca39c0ed4e1772613d3cba13091c0e9d3b89365e84b9bf9838259a3cd8f/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:03f21ade2d72978c2bb8670e9b6de5260e2755092b02d94b70b906813662998d", size = 9080167, upload-time = "2025-12-16T03:25:00.946Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/a942d7119d3e8911094d157598ae0169b1c6ca1bd3f27d7991b279bcc45b/curl_cffi-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:58ebf02de64ee5c95613209ddacb014c2d2f86298d7080c0a1c12ed876ee0690", size = 9520464, upload-time = "2025-12-16T03:25:02.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/78900e9b0833066d2274bda75cba426fdb4cef7fbf6a4f6a6ca447607bec/curl_cffi-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:6e503f9a103f6ae7acfb3890c843b53ec030785a22ae7682a22cc43afb94123e", size = 1677416, upload-time = "2025-12-16T03:25:04.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/d2ba86b0b3e1e2830bd94163d047de122c69a8df03c5c7c36326c456ad82/curl_cffi-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:2eed50a969201605c863c4c31269dfc3e0da52916086ac54553cfa353022425c", size = 1425067, upload-time = "2025-12-16T03:25:06.454Z" }, ] [[package]] @@ -267,6 +312,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -331,6 +428,156 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +] + +[[package]] +name = "msgspec" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/59/fdcb3af72f750a8de2bcf39d62ada70b5eb17b06d7f63860e0a679cb656b/msgspec-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:09e0efbf1ac641fedb1d5496c59507c2f0dc62a052189ee62c763e0aae217520", size = 193345, upload-time = "2025-11-24T03:55:20.613Z" }, + { url = "https://files.pythonhosted.org/packages/5a/15/3c225610da9f02505d37d69a77f4a2e7daae2a125f99d638df211ba84e59/msgspec-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23ee3787142e48f5ee746b2909ce1b76e2949fbe0f97f9f6e70879f06c218b54", size = 186867, upload-time = "2025-11-24T03:55:22.4Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/13ab0c547e283bf172f45491edfdea0e2cecb26ae61e3a7b1ae6058b326d/msgspec-0.20.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:81f4ac6f0363407ac0465eff5c7d4d18f26870e00674f8fcb336d898a1e36854", size = 215351, upload-time = "2025-11-24T03:55:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/6b/96/5c095b940de3aa6b43a71ec76275ac3537b21bd45c7499b5a17a429110fa/msgspec-0.20.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb4d873f24ae18cd1334f4e37a178ed46c9d186437733351267e0a269bdf7e53", size = 219896, upload-time = "2025-11-24T03:55:25.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/7a/81a7b5f01af300761087b114dafa20fb97aed7184d33aab64d48874eb187/msgspec-0.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b92b8334427b8393b520c24ff53b70f326f79acf5f74adb94fd361bcff8a1d4e", size = 220389, upload-time = "2025-11-24T03:55:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/70/c0/3d0cce27db9a9912421273d49eab79ce01ecd2fed1a2f1b74af9b445f33c/msgspec-0.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:562c44b047c05cc0384e006fae7a5e715740215c799429e0d7e3e5adf324285a", size = 223348, upload-time = "2025-11-24T03:55:28.311Z" }, + { url = "https://files.pythonhosted.org/packages/89/5e/406b7d578926b68790e390d83a1165a9bfc2d95612a1a9c1c4d5c72ea815/msgspec-0.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:d1dcc93a3ce3d3195985bfff18a48274d0b5ffbc96fa1c5b89da6f0d9af81b29", size = 188713, upload-time = "2025-11-24T03:55:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/47/87/14fe2316624ceedf76a9e94d714d194cbcb699720b210ff189f89ca4efd7/msgspec-0.20.0-cp311-cp311-win_arm64.whl", hash = "sha256:aa387aa330d2e4bd69995f66ea8fdc87099ddeedf6fdb232993c6a67711e7520", size = 174229, upload-time = "2025-11-24T03:55:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6f/1e25eee957e58e3afb2a44b94fa95e06cebc4c236193ed0de3012fff1e19/msgspec-0.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2aba22e2e302e9231e85edc24f27ba1f524d43c223ef5765bd8624c7df9ec0a5", size = 196391, upload-time = "2025-11-24T03:55:32.677Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/af51d090ada641d4b264992a486435ba3ef5b5634bc27e6eb002f71cef7d/msgspec-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:716284f898ab2547fedd72a93bb940375de9fbfe77538f05779632dc34afdfde", size = 188644, upload-time = "2025-11-24T03:55:33.934Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/9709ee093b7742362c2934bfb1bbe791a1e09bed3ea5d8a18ce552fbfd73/msgspec-0.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:558ed73315efa51b1538fa8f1d3b22c8c5ff6d9a2a62eff87d25829b94fc5054", size = 218852, upload-time = "2025-11-24T03:55:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d1/b902d38b6e5ba3bdddbec469bba388d647f960aeed7b5b3623a8debe8a76/msgspec-0.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c1ff8db03be7598b50dd4b4a478d6fe93faae3bd54f4f17aa004d0e46c14c46", size = 196463, upload-time = "2025-11-24T03:55:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/eff0305961a1d9447ec2b02f8c73c8946f22564d302a504185b730c9a761/msgspec-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f6532369ece217fd37c5ebcfd7e981f2615628c21121b7b2df9d3adcf2fd69b8", size = 188650, upload-time = "2025-11-24T03:55:44.761Z" }, + { url = "https://files.pythonhosted.org/packages/99/93/f2ec1ae1de51d3fdee998a1ede6b2c089453a2ee82b5c1b361ed9095064a/msgspec-0.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a1697da2f85a751ac3cc6a97fceb8e937fc670947183fb2268edaf4016d1ee", size = 218834, upload-time = "2025-11-24T03:55:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" }, + { url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" }, + { url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" }, + { url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" }, + { url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" }, + { url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" }, + { url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" }, + { url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" }, + { url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" }, +] + [[package]] name = "ndjson" version = "0.3.1" @@ -365,6 +612,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/b7/bc0cdbc2cc3a66fcac82c79912e135a0110b37b790a14c477f18e18d90cd/nodejs_wheel_binaries-24.11.1-py2.py3-none-win_arm64.whl", hash = "sha256:376b9ea1c4bc1207878975dfeb604f7aa5668c260c6154dcd2af9d42f7734116", size = 39026497, upload-time = "2025-11-18T18:21:54.634Z" }, ] +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1e/745565dca749813db9a093c5ebc4bac1a9475c64d54b95654336ac3ed961/orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0", size = 228391, upload-time = "2026-02-02T15:38:27.757Z" }, + { url = "https://files.pythonhosted.org/packages/46/19/e40f6225da4d3aa0c8dc6e5219c5e87c2063a560fe0d72a88deb59776794/orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0", size = 125188, upload-time = "2026-02-02T15:38:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7e/c4de2babef2c0817fd1f048fd176aa48c37bec8aef53d2fa932983032cce/orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6", size = 128097, upload-time = "2026-02-02T15:38:30.618Z" }, + { url = "https://files.pythonhosted.org/packages/eb/74/233d360632bafd2197f217eee7fb9c9d0229eac0c18128aee5b35b0014fe/orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf", size = 123364, upload-time = "2026-02-02T15:38:32.363Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/af79504981dd31efe20a9e360eb49c15f06df2b40e7f25a0a52d9ae888e8/orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5", size = 129076, upload-time = "2026-02-02T15:38:33.68Z" }, + { url = "https://files.pythonhosted.org/packages/67/e2/da898eb68b72304f8de05ca6715870d09d603ee98d30a27e8a9629abc64b/orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892", size = 141705, upload-time = "2026-02-02T15:38:34.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/89/15364d92acb3d903b029e28d834edb8780c2b97404cbf7929aa6b9abdb24/orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e", size = 130855, upload-time = "2026-02-02T15:38:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/ecdad52d0b38d4b8f514be603e69ccd5eacf4e7241f972e37e79792212ec/orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1", size = 133386, upload-time = "2026-02-02T15:38:37.704Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/45e1dcf10e17d0924b7c9162f87ec7b4ca79e28a0548acf6a71788d3e108/orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183", size = 138295, upload-time = "2026-02-02T15:38:39.096Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/4d2e8b03561257af0450f2845b91fbd111d7e526ccdf737267108075e0ba/orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650", size = 408720, upload-time = "2026-02-02T15:38:40.634Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/d45343518282108b29c12a65892445fc51f9319dc3c552ceb51bb5905ed2/orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141", size = 144152, upload-time = "2026-02-02T15:38:42.262Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3a/d6001f51a7275aacd342e77b735c71fa04125a3f93c36fee4526bc8c654e/orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2", size = 134814, upload-time = "2026-02-02T15:38:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d3/f19b47ce16820cc2c480f7f1723e17f6d411b3a295c60c8ad3aa9ff1c96a/orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576", size = 127997, upload-time = "2026-02-02T15:38:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/12/df/172771902943af54bf661a8d102bdf2e7f932127968080632bda6054b62c/orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1", size = 124985, upload-time = "2026-02-02T15:38:46.388Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1c/f2a8d8a1b17514660a614ce5f7aac74b934e69f5abc2700cc7ced882a009/orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d", size = 126038, upload-time = "2026-02-02T15:38:47.703Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -374,6 +689,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "patchright" +version = "1.56.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/d6/ac03353f1b3138541356a044c2d7d3e01b73f9555c7281dab3423c5d44da/patchright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9df1e0b2c35a298ad2807044699476d3e872f76117c319077b68d7b8ac8bffae", size = 40595069, upload-time = "2025-11-11T18:59:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f2/e33cd3e6916ed959f5b9be1182363e19771be81541a77b2c5615a6434cc8/patchright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d8e86d743f4da99f83e9962f9317bd3aaabe4e9db86d3e12cb1d0f39e2f469", size = 39383869, upload-time = "2025-11-11T18:59:23.829Z" }, + { url = "https://files.pythonhosted.org/packages/5f/49/510b8fb761a7e6a2cbd8381f2254fd9442055e27961c0565944d5634ccb6/patchright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:e939a9651ebad6ed5afb84e6cf8b874fd80e4b03a912509dc5159f0fa2d4a752", size = 40595069, upload-time = "2025-11-11T18:59:26.9Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ad/17f72b906a1d3d9750b26964d4481340defbfc02f039c888756061f41a33/patchright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:cef0f31334dece118fa8dbe20e0c0ac97ea539a2a3e7dd007a67d05b4c3e0512", size = 46246766, upload-time = "2025-11-11T18:59:29.985Z" }, + { url = "https://files.pythonhosted.org/packages/53/b4/6546fb868f464c888cee8e95d5b0cc4e276afc5e531bc3ca737e2a910f77/patchright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02329457d4f9e92b49209adceec33097042b6a6f03de31185a2f99721aeded6a", size = 46094291, upload-time = "2025-11-11T18:59:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/a43f86570cb72f732723c19ba71f1c02200ed1cc762d71daa2c35718a529/patchright-1.56.0-py3-none-win32.whl", hash = "sha256:5502899cd8764ccc7a18c3cb47a76e49b25ec4eae155ec5ebea9e5e0022cc811", size = 35623487, upload-time = "2025-11-11T18:59:36.156Z" }, + { url = "https://files.pythonhosted.org/packages/95/8d/37cc3127c6085ffa6482832a07f84587f2f04dbbaf194cc395bb2afa811c/patchright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:0fe85d8f6b541689185115f3ce69e87a7f5dd964ec6aaeac635d4db5a906aec1", size = 35623491, upload-time = "2025-11-11T18:59:38.853Z" }, + { url = "https://files.pythonhosted.org/packages/c5/37/0b79ddfc6a2ba16e9f258670ea40a74c791d925f1847cf4ad0e739bc7506/patchright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:8bbf1c753e7b07b889a3ff43109ce3cea457605ea6b0477e65af4d9a2c5585cf", size = 31232602, upload-time = "2025-11-11T18:59:41.537Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -383,6 +717,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[package]] +name = "playwright" +version = "1.56.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/a5362cee43f844509f1f10d8a27c9cc0e2f7bdce5353d304d93b2151c1b1/playwright-1.56.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:b33eb89c516cbc6723f2e3523bada4a4eb0984a9c411325c02d7016a5d625e9c", size = 40611424, upload-time = "2025-11-11T18:39:10.175Z" }, + { url = "https://files.pythonhosted.org/packages/ef/95/347eef596d8778fb53590dc326c344d427fa19ba3d42b646fce2a4572eb3/playwright-1.56.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b228b3395212b9472a4ee5f1afe40d376eef9568eb039fcb3e563de8f4f4657b", size = 39400228, upload-time = "2025-11-11T18:39:13.915Z" }, + { url = "https://files.pythonhosted.org/packages/b9/54/6ad97b08b2ca1dfcb4fbde4536c4f45c0d9d8b1857a2d20e7bbfdf43bf15/playwright-1.56.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:0ef7e6fd653267798a8a968ff7aa2dcac14398b7dd7440ef57524e01e0fbbd65", size = 40611424, upload-time = "2025-11-11T18:39:17.093Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/6d409e37e82cdd5dda3df1ab958130ae32b46e42458bd4fc93d7eb8749cb/playwright-1.56.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:404be089b49d94bc4c1fe0dfb07664bda5ffe87789034a03bffb884489bdfb5c", size = 46263122, upload-time = "2025-11-11T18:39:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/4f/84/fb292cc5d45f3252e255ea39066cd1d2385c61c6c1596548dfbf59c88605/playwright-1.56.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64cda7cf4e51c0d35dab55190841bfcdfb5871685ec22cb722cd0ad2df183e34", size = 46110645, upload-time = "2025-11-11T18:39:24.005Z" }, + { url = "https://files.pythonhosted.org/packages/61/bd/8c02c3388ae14edc374ac9f22cbe4e14826c6a51b2d8eaf86e89fabee264/playwright-1.56.0-py3-none-win32.whl", hash = "sha256:d87b79bcb082092d916a332c27ec9732e0418c319755d235d93cc6be13bdd721", size = 35639837, upload-time = "2025-11-11T18:39:27.174Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/f13b538fbc6b7a00152f4379054a49f6abc0bf55ac86f677ae54bc49fb82/playwright-1.56.0-py3-none-win_amd64.whl", hash = "sha256:3c7fc49bb9e673489bf2622855f9486d41c5101bbed964638552b864c4591f94", size = 35639843, upload-time = "2025-11-11T18:39:30.851Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c7/3ee8b556107995846576b4fe42a08ed49b8677619421f2afacf6ee421138/playwright-1.56.0-py3-none-win_arm64.whl", hash = "sha256:2745490ae8dd58d27e5ea4d9aa28402e8e2991eb84fb4b2fd5fbde2106716f6f", size = 31248959, upload-time = "2025-11-11T18:39:33.998Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -529,6 +882,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -674,6 +1039,7 @@ dependencies = [ { name = "ndjson" }, { name = "pydantic" }, { name = "requests" }, + { name = "scrapling", extra = ["fetchers"] }, ] [package.dev-dependencies] @@ -697,6 +1063,7 @@ requires-dist = [ { name = "ndjson", specifier = ">=0.3.1" }, { name = "pydantic", specifier = ">=2.11.10" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "scrapling", extras = ["fetchers"], specifier = ">=0.4" }, ] [package.metadata.requires-dev] @@ -711,6 +1078,34 @@ dev = [ { name = "types-requests", specifier = ">=2.32.4.20250913" }, ] +[[package]] +name = "scrapling" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cssselect" }, + { name = "lxml" }, + { name = "orjson" }, + { name = "tld" }, + { name = "typing-extensions" }, + { name = "w3lib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/fa/375d4b78b0c320c46b81464d685c2c3c3138370ae6ac86c785aaab781d5a/scrapling-0.4.1.tar.gz", hash = "sha256:e2c82b6ce9dee2c06060420f862d14c4e2a3c4e8e37e9df76cdbf90dc9d90505", size = 105687, upload-time = "2026-02-27T04:13:19.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/7c/7496abccf70473f469521643160b16f0dd2a17bdea18c6825e7240d8dc79/scrapling-0.4.1-py3-none-any.whl", hash = "sha256:b74696b32abcea559ef2dd23989143ac8e733959af2fdd4fb01fcd31ad8cdece", size = 116071, upload-time = "2026-02-27T04:13:17.674Z" }, +] + +[package.optional-dependencies] +fetchers = [ + { name = "anyio" }, + { name = "browserforge" }, + { name = "click" }, + { name = "curl-cffi" }, + { name = "msgspec" }, + { name = "patchright" }, + { name = "playwright" }, +] + [[package]] name = "soupsieve" version = "2.8" @@ -720,6 +1115,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, ] +[[package]] +name = "tld" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/a1/5723b07a70c1841a80afc9ac572fdf53488306848d844cd70519391b0d26/tld-0.13.1.tar.gz", hash = "sha256:75ec00936cbcf564f67361c41713363440b6c4ef0f0c1592b5b0fbe72c17a350", size = 462000, upload-time = "2025-05-21T22:18:29.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/70/b2f38360c3fc4bc9b5e8ef429e1fde63749144ac583c2dbdf7e21e27a9ad/tld-0.13.1-py2.py3-none-any.whl", hash = "sha256:a2d35109433ac83486ddf87e3c4539ab2c5c2478230e5d9c060a18af4b03aa7c", size = 274718, upload-time = "2025-05-21T22:18:25.811Z" }, +] + [[package]] name = "ty" version = "0.0.1a32" @@ -833,3 +1237,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846 wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] + +[[package]] +name = "w3lib" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/91/186665abf1a6d16c0c5ea1f0e681d9c852b45c3a750aa8657f8f956690a8/w3lib-2.4.0.tar.gz", hash = "sha256:e233ad21649b69d0e047a10f30181ae9677524a29f6f71f6f3c758dc0c8d2648", size = 48302, upload-time = "2026-01-29T07:05:07.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/f5/ce3ab627e0cb51591c9e3dc4b9b173f15d7f2bec1c0010420b15fc442940/w3lib-2.4.0-py3-none-any.whl", hash = "sha256:260b5a22aeb86ae73213857f69ed20829a45150f8d5b12050b1f02ada414db79", size = 21603, upload-time = "2026-01-29T07:05:05.841Z" }, +] From e67426552739a2b52ec1192418a3c12ab934c02f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:37:29 -0500 Subject: [PATCH 076/114] fix(setup): prevent spurious swap file warnings on `:CP` (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `setup_problem` explicitly set `swapfile = true` on provisional buffers, overriding the user's global `noswapfile` setting. The resulting `.swp` files triggered E325 warnings on subsequent `:e` calls — especially during the restore path, which redundantly re-opened the current buffer. ## Solution Remove the `swapfile` override so the user's setting is respected, and skip the `:e` call in `setup_problem` when the current buffer already matches the target source file. --- lua/cp/setup.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index e96c37f..19fa776 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -294,7 +294,6 @@ function M.setup_problem(problem_id, language) state.set_provisional(nil) else vim.api.nvim_buf_set_name(prov.bufnr, source_file) - vim.bo[prov.bufnr].swapfile = true -- selene: allow(mixed_table) vim.cmd.write({ vim.fn.fnameescape(source_file), @@ -343,7 +342,10 @@ function M.setup_problem(problem_id, language) end vim.cmd.only({ mods = { silent = true } }) - vim.cmd.e(source_file) + local current_file = vim.fn.expand('%:p') + if current_file ~= vim.fn.fnamemodify(source_file, ':p') then + vim.cmd.e(source_file) + end local bufnr = vim.api.nvim_get_current_buf() state.set_solution_win(vim.api.nvim_get_current_win()) require('cp.ui.views').ensure_io_view() From e9f72dfbbc81c40361af60c38f8a64eadf94962c Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 01:07:57 -0500 Subject: [PATCH 077/114] feat(cses): implement submit via REST API (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem CSES submit was a stub returning "not yet implemented". ## Solution Authenticate via web login + API token bridge (POST `/login` form, then POST `/api/login` and confirm the auth page), submit source to `/api/courses/problemset/submissions` with base64-encoded content, and poll for verdict. Uses the same username/password credential model as AtCoder — no browser dependencies needed. Tested end-to-end with a real CSES account (verdict: `ACCEPTED`). Also updates `scraper.lua` to pass the full ndjson event object to `on_status` and handle `credentials` events for future platform use. --- lua/cp/scraper.lua | 5 +- lua/cp/submit.lua | 5 +- scrapers/cses.py | 155 +++++++++++++++++++++++++++++++++++++++++++-- t/1068.cc | 54 ++++++++++++++++ 4 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 t/1068.cc diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 7ddca0a..fc8ba69 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -298,9 +298,12 @@ function M.submit( stdin = source_code, env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) }, on_event = function(ev) + if ev.credentials ~= nil then + require('cp.cache').set_credentials(platform, ev.credentials) + end if ev.status ~= nil then if type(on_status) == 'function' then - on_status(ev.status) + on_status(ev) end elseif ev.success ~= nil then done = true diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 4efe25e..7dc9a71 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -25,6 +25,7 @@ local function prompt_credentials(platform, callback) vim.fn.inputsave() local password = vim.fn.inputsecret(platform .. ' password: ') vim.fn.inputrestore() + vim.cmd.redraw() if not password or password == '' then logger.log('Submit cancelled', vim.log.levels.WARN) return @@ -64,9 +65,9 @@ function M.submit(opts) language, source_code, creds, - function(status) + function(ev) vim.schedule(function() - vim.notify('[cp.nvim] ' .. (STATUS_MSGS[status] or status), vim.log.levels.INFO) + vim.notify('[cp.nvim] ' .. (STATUS_MSGS[ev.status] or ev.status), vim.log.levels.INFO) end) end, function(result) diff --git a/scrapers/cses.py b/scrapers/cses.py index 473558f..b2e845a 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import asyncio +import base64 import json import re from typing import Any @@ -18,6 +19,8 @@ from .models import ( ) BASE_URL = "https://cses.fi" +API_URL = "https://cses.fi/api" +SUBMIT_SCOPE = "courses/problemset" INDEX_PATH = "/problemset" TASK_PATH = "/problemset/task/{id}" HEADERS = { @@ -26,6 +29,16 @@ HEADERS = { TIMEOUT_S = 15.0 CONNECTIONS = 8 +CSES_LANGUAGES: dict[str, dict[str, str]] = { + "C++17": {"name": "C++", "option": "C++17"}, + "Python3": {"name": "Python", "option": "CPython3"}, +} + +EXTENSIONS: dict[str, str] = { + "C++17": "cpp", + "Python3": "py", +} + def normalize_category_name(category_name: str) -> str: return category_name.lower().replace(" ", "_").replace("&", "and") @@ -270,6 +283,65 @@ class CSESScraper(BaseScraper): payload = await coro print(json.dumps(payload), flush=True) + async def _web_login( + self, + client: httpx.AsyncClient, + username: str, + password: str, + ) -> str | None: + login_page = await client.get( + f"{BASE_URL}/login", headers=HEADERS, timeout=TIMEOUT_S + ) + csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', login_page.text) + if not csrf_match: + return None + + login_resp = await client.post( + f"{BASE_URL}/login", + data={ + "csrf_token": csrf_match.group(1), + "nick": username, + "pass": password, + }, + headers=HEADERS, + timeout=TIMEOUT_S, + ) + + if "Invalid username or password" in login_resp.text: + return None + + api_resp = await client.post( + f"{API_URL}/login", headers=HEADERS, timeout=TIMEOUT_S + ) + api_data = api_resp.json() + token: str = api_data["X-Auth-Token"] + auth_url: str = api_data["authentication_url"] + + auth_page = await client.get(auth_url, headers=HEADERS, timeout=TIMEOUT_S) + auth_csrf = re.search(r'name="csrf_token" value="([^"]+)"', auth_page.text) + form_token = re.search(r'name="token" value="([^"]+)"', auth_page.text) + if not auth_csrf or not form_token: + return None + + await client.post( + auth_url, + data={ + "csrf_token": auth_csrf.group(1), + "token": form_token.group(1), + }, + headers=HEADERS, + timeout=TIMEOUT_S, + ) + + check = await client.get( + f"{API_URL}/login", + headers={"X-Auth-Token": token, **HEADERS}, + timeout=TIMEOUT_S, + ) + if check.status_code != 200: + return None + return token + async def submit( self, contest_id: str, @@ -278,12 +350,83 @@ class CSESScraper(BaseScraper): language_id: str, credentials: dict[str, str], ) -> SubmitResult: - return SubmitResult( - success=False, - error="CSES submit not yet implemented", - submission_id="", - verdict="", - ) + username = credentials.get("username", "") + password = credentials.get("password", "") + if not username or not password: + return self._submit_error("Missing credentials. Use :CP login cses") + + async with httpx.AsyncClient(follow_redirects=True) as client: + print(json.dumps({"status": "logging_in"}), flush=True) + + token = await self._web_login(client, username, password) + if not token: + return self._submit_error("Login failed (bad credentials?)") + + print(json.dumps({"status": "submitting"}), flush=True) + + ext = EXTENSIONS.get(language_id, "cpp") + lang = CSES_LANGUAGES.get(language_id, {}) + content_b64 = base64.b64encode(source_code.encode()).decode() + + payload: dict[str, Any] = { + "language": lang, + "filename": f"{problem_id}.{ext}", + "content": content_b64, + } + + r = await client.post( + f"{API_URL}/{SUBMIT_SCOPE}/submissions", + json=payload, + params={"task": problem_id}, + headers={ + "X-Auth-Token": token, + "Content-Type": "application/json", + **HEADERS, + }, + timeout=TIMEOUT_S, + ) + + if r.status_code not in range(200, 300): + try: + err = r.json().get("message", r.text) + except Exception: + err = r.text + return self._submit_error(f"Submit request failed: {err}") + + info = r.json() + submission_id = str(info.get("id", "")) + + for _ in range(60): + await asyncio.sleep(2) + try: + r = await client.get( + f"{API_URL}/{SUBMIT_SCOPE}/submissions/{submission_id}", + params={"poll": "true"}, + headers={ + "X-Auth-Token": token, + **HEADERS, + }, + timeout=30.0, + ) + if r.status_code == 200: + info = r.json() + if not info.get("pending", True): + verdict = info.get("result", "unknown") + return SubmitResult( + success=True, + error="", + submission_id=submission_id, + verdict=verdict, + ) + except Exception: + pass + + return SubmitResult( + success=True, + error="", + submission_id=submission_id, + verdict="submitted (poll timed out)", + ) if __name__ == "__main__": diff --git a/t/1068.cc b/t/1068.cc new file mode 100644 index 0000000..5d3fe37 --- /dev/null +++ b/t/1068.cc @@ -0,0 +1,54 @@ +#include // {{{ + +#include +#ifdef __cpp_lib_ranges_enumerate +#include +namespace rv = std::views; +namespace rs = std::ranges; +#endif + +#pragma GCC optimize("O2,unroll-loops") +#pragma GCC target("avx2,bmi,bmi2,lzcnt,popcnt") + +using namespace std; + +using i32 = int32_t; +using u32 = uint32_t; +using i64 = int64_t; +using u64 = uint64_t; +using f64 = double; +using f128 = long double; + +#if __cplusplus >= 202002L +template +constexpr T MIN = std::numeric_limits::min(); + +template +constexpr T MAX = std::numeric_limits::max(); +#endif + +#ifdef LOCAL +#define db(...) std::print(__VA_ARGS__) +#define dbln(...) std::println(__VA_ARGS__) +#else +#define db(...) +#define dbln(...) +#endif +// }}} + +void solve() { + cout << "hi\n"; +} + +int main() { // {{{ + std::cin.exceptions(std::cin.failbit); +#ifdef LOCAL + std::cerr.rdbuf(std::cout.rdbuf()); + std::cout.setf(std::ios::unitbuf); + std::cerr.setf(std::ios::unitbuf); +#else + std::cin.tie(nullptr)->sync_with_stdio(false); +#endif + solve(); + return 0; +} // }}} From 6fcb5d1bbcc6c1d11c01eff351b1f2abe6d1988b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:37:39 -0500 Subject: [PATCH 078/114] feat(codeforces): implement submit; cache CSES token (#300) ## Problem Codeforces submit was a stub. CSES submit re-ran the full login flow on every invocation (~1.5s overhead). ## Solution **Codeforces**: headless browser submit via StealthySession (same pattern as AtCoder). Solves Cloudflare Turnstile on login, uploads source via file input, caches cookies at `~/.cache/cp-nvim/codeforces-cookies.json` so repeat submits skip login. **CSES**: persist the API token in credentials via a `credentials` ndjson event. Subsequent submits validate the cached token with a single GET before falling back to full login. Also includes a vimdoc table of contents. --- doc/cp.nvim.txt | 31 +++++++ scrapers/atcoder.py | 41 +++++---- scrapers/codechef.py | 6 +- scrapers/codeforces.py | 194 +++++++++++++++++++++++++++++++++++++++-- scrapers/cses.py | 56 +++++++++--- scrapers/kattis.py | 6 +- scrapers/timeouts.py | 9 ++ scrapers/usaco.py | 6 +- t/1068.cc | 54 ------------ 9 files changed, 307 insertions(+), 96 deletions(-) create mode 100644 scrapers/timeouts.py delete mode 100644 t/1068.cc diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index f61d39e..2c0cc5c 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -3,6 +3,37 @@ Author: Barrett Ruth License: Same terms as Vim itself (see |license|) +============================================================================== +CONTENTS *cp-contents* + + 1. Introduction .................................................. |cp.nvim| + 2. Requirements ........................................ |cp-requirements| + 3. Setup ........................................................ |cp-setup| + 4. Configuration ................................................ |cp-config| + 5. Commands .................................................. |cp-commands| + 6. Mappings .................................................. |cp-mappings| + 7. Language Selection .................................. |cp-lang-selection| + 8. Workflow .................................................. |cp-workflow| + 9. Workflow Example ............................................ |cp-example| + 10. Verdict Formatting ................................. |cp-verdict-format| + 11. Picker Integration .......................................... |cp-picker| + 12. Picker Keymaps ........................................ |cp-picker-keys| + 13. Panel ........................................................ |cp-panel| + 14. Interactive Mode .......................................... |cp-interact| + 15. Stress Testing .............................................. |cp-stress| + 16. Race .......................................................... |cp-race| + 17. Credentials ............................................ |cp-credentials| + 18. Submit ...................................................... |cp-submit| + 19. ANSI Colors ................................................... |cp-ansi| + 20. Highlight Groups ........................................ |cp-highlights| + 21. Terminal Colors .................................... |cp-terminal-colors| + 22. Highlight Customization .......................... |cp-highlight-custom| + 23. Helpers .................................................... |cp-helpers| + 24. Statusline Integration .................................. |cp-statusline| + 25. Panel Keymaps .......................................... |cp-panel-keys| + 26. File Structure ................................................ |cp-files| + 27. Health Check ................................................ |cp-health| + ============================================================================== INTRODUCTION *cp.nvim* diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 8be75ff..45a2195 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -29,11 +29,18 @@ from .models import ( TestCase, TestsResult, ) +from .timeouts import ( + BROWSER_ELEMENT_WAIT, + BROWSER_NAV_TIMEOUT, + BROWSER_SESSION_TIMEOUT, + BROWSER_SETTLE_DELAY, + BROWSER_TURNSTILE_POLL, + HTTP_TIMEOUT, +) MIB_TO_MB = 1.048576 BASE_URL = "https://atcoder.jp" ARCHIVE_URL = f"{BASE_URL}/contests/archive" -TIMEOUT_SECONDS = 30 HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } @@ -76,7 +83,7 @@ def _retry_after_requests(details): on_backoff=_retry_after_requests, ) def _fetch(url: str) -> str: - r = _session.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS) + r = _session.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT) if r.status_code in RETRY_STATUS: raise requests.HTTPError(response=r) r.raise_for_status() @@ -99,7 +106,7 @@ def _giveup_httpx(exc: Exception) -> bool: giveup=_giveup_httpx, ) async def _get_async(client: httpx.AsyncClient, url: str) -> str: - r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS) + r = await client.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT) r.raise_for_status() return r.text @@ -239,14 +246,14 @@ _TURNSTILE_JS = "() => { const el = document.querySelector('[name=\"cf-turnstile def _solve_turnstile(page) -> None: + if page.evaluate(_TURNSTILE_JS): + return + iframe_loc = page.locator('iframe[src*="challenges.cloudflare.com"]') + if not iframe_loc.count(): + return for _ in range(6): - has_token = page.evaluate(_TURNSTILE_JS) - if has_token: - return try: - box = page.locator( - 'iframe[src*="challenges.cloudflare.com"]' - ).first.bounding_box() + box = iframe_loc.first.bounding_box() if box: page.mouse.click( box["x"] + box["width"] * 0.15, @@ -255,7 +262,7 @@ def _solve_turnstile(page) -> None: except Exception: pass try: - page.wait_for_function(_TURNSTILE_JS, timeout=5000) + page.wait_for_function(_TURNSTILE_JS, timeout=BROWSER_TURNSTILE_POLL) return except Exception: pass @@ -331,7 +338,9 @@ def _submit_headless( page.fill('input[name="username"]', credentials.get("username", "")) page.fill('input[name="password"]', credentials.get("password", "")) page.click("#submit") - page.wait_for_url(lambda url: "/login" not in url, timeout=60000) + page.wait_for_url( + lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT + ) except Exception as e: login_error = str(e) @@ -345,7 +354,7 @@ def _submit_headless( ) page.locator( f'select[name="data.LanguageId"] option[value="{language_id}"]' - ).wait_for(state="attached", timeout=15000) + ).wait_for(state="attached", timeout=BROWSER_ELEMENT_WAIT) page.select_option('select[name="data.LanguageId"]', language_id) with tempfile.NamedTemporaryFile( mode="w", suffix=".cpp", delete=False, prefix="atcoder_" @@ -354,18 +363,20 @@ def _submit_headless( tmp_path = tf.name try: page.set_input_files("#input-open-file", tmp_path) - page.wait_for_timeout(500) + page.wait_for_timeout(BROWSER_SETTLE_DELAY) finally: os.unlink(tmp_path) page.locator('button[type="submit"]').click() - page.wait_for_url(lambda url: "/submissions/me" in url, timeout=60000) + page.wait_for_url( + lambda url: "/submissions/me" in url, timeout=BROWSER_NAV_TIMEOUT + ) except Exception as e: submit_error = str(e) try: with StealthySession( headless=True, - timeout=60000, + timeout=BROWSER_SESSION_TIMEOUT, google_search=False, cookies=saved_cookies, ) as session: diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 57ce33e..c4b9d37 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -9,6 +9,7 @@ import httpx from curl_cffi import requests as curl_requests from .base import BaseScraper, extract_precision +from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, @@ -26,7 +27,6 @@ PROBLEM_URL = "https://www.codechef.com/problems/{problem_id}" HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } -TIMEOUT_S = 15.0 CONNECTIONS = 8 MEMORY_LIMIT_RE = re.compile( r"Memory\s+[Ll]imit.*?([0-9.]+)\s*(MB|GB)", re.IGNORECASE | re.DOTALL @@ -34,7 +34,7 @@ MEMORY_LIMIT_RE = re.compile( async def fetch_json(client: httpx.AsyncClient, path: str) -> dict: - r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S) + r = await client.get(BASE_URL + path, headers=HEADERS, timeout=HTTP_TIMEOUT) r.raise_for_status() return r.json() @@ -51,7 +51,7 @@ def _extract_memory_limit(html: str) -> float: def _fetch_html_sync(url: str) -> str: - response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S) + response = curl_requests.get(url, impersonate="chrome", timeout=HTTP_TIMEOUT) response.raise_for_status() return response.text diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index c0495d8..fd0c129 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -2,7 +2,9 @@ import asyncio import json +import os import re +import tempfile from typing import Any import requests @@ -18,10 +20,16 @@ from .models import ( SubmitResult, TestCase, ) +from .timeouts import ( + BROWSER_ELEMENT_WAIT, + BROWSER_NAV_TIMEOUT, + BROWSER_SESSION_TIMEOUT, + BROWSER_SETTLE_DELAY, + HTTP_TIMEOUT, +) BASE_URL = "https://codeforces.com" API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list" -TIMEOUT_SECONDS = 30 HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } @@ -136,7 +144,7 @@ def _is_interactive(block: Tag) -> bool: def _fetch_problems_html(contest_id: str) -> str: url = f"{BASE_URL}/contest/{contest_id}/problems" - response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS) + response = curl_requests.get(url, impersonate="chrome", timeout=HTTP_TIMEOUT) response.raise_for_status() return response.text @@ -223,7 +231,7 @@ class CodeforcesScraper(BaseScraper): async def scrape_contest_list(self) -> ContestListResult: try: - r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS) + r = requests.get(API_CONTEST_LIST_URL, timeout=HTTP_TIMEOUT) r.raise_for_status() data = r.json() if data.get("status") != "OK": @@ -289,13 +297,187 @@ class CodeforcesScraper(BaseScraper): language_id: str, credentials: dict[str, str], ) -> SubmitResult: + return await asyncio.to_thread( + _submit_headless, + contest_id, + problem_id, + source_code, + language_id, + credentials, + ) + + +def _wait_for_gate_reload(page, wait_selector: str) -> None: + from .atcoder import _solve_turnstile + + if "Verification" not in page.title(): + return + _solve_turnstile(page) + page.wait_for_function( + f"() => !!document.querySelector('{wait_selector}')", + timeout=BROWSER_ELEMENT_WAIT, + ) + + +def _submit_headless( + contest_id: str, + problem_id: str, + source_code: str, + language_id: str, + credentials: dict[str, str], +) -> SubmitResult: + from pathlib import Path + + try: + from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] + except ImportError: return SubmitResult( success=False, - error="Codeforces submit not yet implemented", - submission_id="", - verdict="", + error="scrapling is required for Codeforces submit", ) + from .atcoder import _ensure_browser, _solve_turnstile + + _ensure_browser() + + cookie_cache = Path.home() / ".cache" / "cp-nvim" / "codeforces-cookies.json" + cookie_cache.parent.mkdir(parents=True, exist_ok=True) + saved_cookies: list[dict[str, Any]] = [] + if cookie_cache.exists(): + try: + saved_cookies = json.loads(cookie_cache.read_text()) + except Exception: + pass + + logged_in = False + login_error: str | None = None + submit_error: str | None = None + + def check_login(page): + nonlocal logged_in + logged_in = page.evaluate( + "() => Array.from(document.querySelectorAll('a'))" + ".some(a => a.textContent.includes('Logout'))" + ) + + def login_action(page): + nonlocal login_error + try: + _wait_for_gate_reload(page, "#enterForm") + except Exception: + pass + try: + page.fill( + 'input[name="handleOrEmail"]', + credentials.get("username", ""), + ) + page.fill( + 'input[name="password"]', + credentials.get("password", ""), + ) + page.locator('#enterForm input[type="submit"]').click() + page.wait_for_url( + lambda url: "/enter" not in url, timeout=BROWSER_NAV_TIMEOUT + ) + except Exception as e: + login_error = str(e) + + def submit_action(page): + nonlocal submit_error + try: + _solve_turnstile(page) + except Exception: + pass + tmp_path: str | None = None + try: + page.select_option( + 'select[name="submittedProblemIndex"]', + problem_id.upper(), + ) + page.select_option('select[name="programTypeId"]', language_id) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".cpp", delete=False, prefix="cf_" + ) as tf: + tf.write(source_code) + tmp_path = tf.name + try: + page.set_input_files('input[name="sourceFile"]', tmp_path) + page.wait_for_timeout(BROWSER_SETTLE_DELAY) + except Exception: + page.fill('textarea[name="source"]', source_code) + page.locator("form.submit-form input.submit").click(no_wait_after=True) + try: + page.wait_for_url( + lambda url: "/my" in url or "/status" in url, + timeout=BROWSER_NAV_TIMEOUT * 2, + ) + except Exception: + err_el = page.query_selector("span.error") + if err_el: + submit_error = err_el.inner_text().strip() + else: + submit_error = "Submit failed: page did not navigate" + except Exception as e: + submit_error = str(e) + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass + + try: + with StealthySession( + headless=True, + timeout=BROWSER_SESSION_TIMEOUT, + google_search=False, + cookies=saved_cookies, + ) as session: + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/", + page_action=check_login, + network_idle=True, + ) + + if not logged_in: + print(json.dumps({"status": "logging_in"}), flush=True) + session.fetch( + f"{BASE_URL}/enter", + page_action=login_action, + solve_cloudflare=True, + ) + if login_error: + return SubmitResult( + success=False, error=f"Login failed: {login_error}" + ) + + print(json.dumps({"status": "submitting"}), flush=True) + session.fetch( + f"{BASE_URL}/contest/{contest_id}/submit", + page_action=submit_action, + solve_cloudflare=True, + ) + + try: + browser_cookies = session.context.cookies() + if any(c["name"] == "JSESSIONID" for c in browser_cookies): + cookie_cache.write_text(json.dumps(browser_cookies)) + except Exception: + pass + + if submit_error: + return SubmitResult(success=False, error=submit_error) + + return SubmitResult( + success=True, + error="", + submission_id="", + verdict="submitted", + ) + except Exception as e: + return SubmitResult(success=False, error=str(e)) + if __name__ == "__main__": CodeforcesScraper().run_cli() diff --git a/scrapers/cses.py b/scrapers/cses.py index b2e845a..7d9f4f0 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -9,6 +9,7 @@ from typing import Any import httpx from .base import BaseScraper, extract_precision +from .timeouts import HTTP_TIMEOUT, SUBMIT_POLL_TIMEOUT from .models import ( ContestListResult, ContestSummary, @@ -26,7 +27,6 @@ TASK_PATH = "/problemset/task/{id}" HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } -TIMEOUT_S = 15.0 CONNECTIONS = 8 CSES_LANGUAGES: dict[str, dict[str, str]] = { @@ -78,7 +78,7 @@ def snake_to_title(name: str) -> str: async def fetch_text(client: httpx.AsyncClient, path: str) -> str: - r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S) + r = await client.get(BASE_URL + path, headers=HEADERS, timeout=HTTP_TIMEOUT) r.raise_for_status() return r.text @@ -290,7 +290,7 @@ class CSESScraper(BaseScraper): password: str, ) -> str | None: login_page = await client.get( - f"{BASE_URL}/login", headers=HEADERS, timeout=TIMEOUT_S + f"{BASE_URL}/login", headers=HEADERS, timeout=HTTP_TIMEOUT ) csrf_match = re.search(r'name="csrf_token" value="([^"]+)"', login_page.text) if not csrf_match: @@ -304,20 +304,20 @@ class CSESScraper(BaseScraper): "pass": password, }, headers=HEADERS, - timeout=TIMEOUT_S, + timeout=HTTP_TIMEOUT, ) if "Invalid username or password" in login_resp.text: return None api_resp = await client.post( - f"{API_URL}/login", headers=HEADERS, timeout=TIMEOUT_S + f"{API_URL}/login", headers=HEADERS, timeout=HTTP_TIMEOUT ) api_data = api_resp.json() token: str = api_data["X-Auth-Token"] auth_url: str = api_data["authentication_url"] - auth_page = await client.get(auth_url, headers=HEADERS, timeout=TIMEOUT_S) + auth_page = await client.get(auth_url, headers=HEADERS, timeout=HTTP_TIMEOUT) auth_csrf = re.search(r'name="csrf_token" value="([^"]+)"', auth_page.text) form_token = re.search(r'name="token" value="([^"]+)"', auth_page.text) if not auth_csrf or not form_token: @@ -330,18 +330,29 @@ class CSESScraper(BaseScraper): "token": form_token.group(1), }, headers=HEADERS, - timeout=TIMEOUT_S, + timeout=HTTP_TIMEOUT, ) check = await client.get( f"{API_URL}/login", headers={"X-Auth-Token": token, **HEADERS}, - timeout=TIMEOUT_S, + timeout=HTTP_TIMEOUT, ) if check.status_code != 200: return None return token + async def _check_token(self, client: httpx.AsyncClient, token: str) -> bool: + try: + r = await client.get( + f"{API_URL}/login", + headers={"X-Auth-Token": token, **HEADERS}, + timeout=HTTP_TIMEOUT, + ) + return r.status_code == 200 + except Exception: + return False + async def submit( self, contest_id: str, @@ -356,11 +367,30 @@ class CSESScraper(BaseScraper): return self._submit_error("Missing credentials. Use :CP login cses") async with httpx.AsyncClient(follow_redirects=True) as client: - print(json.dumps({"status": "logging_in"}), flush=True) + token = credentials.get("token") + + if token: + print(json.dumps({"status": "checking_login"}), flush=True) + if not await self._check_token(client, token): + token = None - token = await self._web_login(client, username, password) if not token: - return self._submit_error("Login failed (bad credentials?)") + print(json.dumps({"status": "logging_in"}), flush=True) + token = await self._web_login(client, username, password) + if not token: + return self._submit_error("Login failed (bad credentials?)") + print( + json.dumps( + { + "credentials": { + "username": username, + "password": password, + "token": token, + } + } + ), + flush=True, + ) print(json.dumps({"status": "submitting"}), flush=True) @@ -383,7 +413,7 @@ class CSESScraper(BaseScraper): "Content-Type": "application/json", **HEADERS, }, - timeout=TIMEOUT_S, + timeout=HTTP_TIMEOUT, ) if r.status_code not in range(200, 300): @@ -406,7 +436,7 @@ class CSESScraper(BaseScraper): "X-Auth-Token": token, **HEADERS, }, - timeout=30.0, + timeout=SUBMIT_POLL_TIMEOUT, ) if r.status_code == 200: info = r.json() diff --git a/scrapers/kattis.py b/scrapers/kattis.py index d1675bf..2bfd2d6 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -10,6 +10,7 @@ from datetime import datetime import httpx from .base import BaseScraper +from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, @@ -23,7 +24,6 @@ BASE_URL = "https://open.kattis.com" HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } -TIMEOUT_S = 15.0 CONNECTIONS = 8 TIME_RE = re.compile( @@ -37,13 +37,13 @@ MEM_RE = re.compile( async def _fetch_text(client: httpx.AsyncClient, url: str) -> str: - r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S) + r = await client.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT) r.raise_for_status() return r.text async def _fetch_bytes(client: httpx.AsyncClient, url: str) -> bytes: - r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S) + r = await client.get(url, headers=HEADERS, timeout=HTTP_TIMEOUT) r.raise_for_status() return r.content diff --git a/scrapers/timeouts.py b/scrapers/timeouts.py new file mode 100644 index 0000000..a21ad0d --- /dev/null +++ b/scrapers/timeouts.py @@ -0,0 +1,9 @@ +HTTP_TIMEOUT = 15.0 + +BROWSER_SESSION_TIMEOUT = 15000 +BROWSER_NAV_TIMEOUT = 10000 +BROWSER_TURNSTILE_POLL = 5000 +BROWSER_ELEMENT_WAIT = 10000 +BROWSER_SETTLE_DELAY = 500 + +SUBMIT_POLL_TIMEOUT = 30.0 diff --git a/scrapers/usaco.py b/scrapers/usaco.py index 565f1b5..9e4d7da 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -8,6 +8,7 @@ from typing import Any, cast import httpx from .base import BaseScraper +from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, @@ -21,7 +22,6 @@ BASE_URL = "http://www.usaco.org" HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } -TIMEOUT_S = 15.0 CONNECTIONS = 4 MONTHS = [ @@ -58,7 +58,9 @@ RESULTS_PAGE_RE = re.compile( async def _fetch_text(client: httpx.AsyncClient, url: str) -> str: - r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_S, follow_redirects=True) + r = await client.get( + url, headers=HEADERS, timeout=HTTP_TIMEOUT, follow_redirects=True + ) r.raise_for_status() return r.text diff --git a/t/1068.cc b/t/1068.cc deleted file mode 100644 index 5d3fe37..0000000 --- a/t/1068.cc +++ /dev/null @@ -1,54 +0,0 @@ -#include // {{{ - -#include -#ifdef __cpp_lib_ranges_enumerate -#include -namespace rv = std::views; -namespace rs = std::ranges; -#endif - -#pragma GCC optimize("O2,unroll-loops") -#pragma GCC target("avx2,bmi,bmi2,lzcnt,popcnt") - -using namespace std; - -using i32 = int32_t; -using u32 = uint32_t; -using i64 = int64_t; -using u64 = uint64_t; -using f64 = double; -using f128 = long double; - -#if __cplusplus >= 202002L -template -constexpr T MIN = std::numeric_limits::min(); - -template -constexpr T MAX = std::numeric_limits::max(); -#endif - -#ifdef LOCAL -#define db(...) std::print(__VA_ARGS__) -#define dbln(...) std::println(__VA_ARGS__) -#else -#define db(...) -#define dbln(...) -#endif -// }}} - -void solve() { - cout << "hi\n"; -} - -int main() { // {{{ - std::cin.exceptions(std::cin.failbit); -#ifdef LOCAL - std::cerr.rdbuf(std::cout.rdbuf()); - std::cout.setf(std::ios::unitbuf); - std::cerr.setf(std::ios::unitbuf); -#else - std::cin.tie(nullptr)->sync_with_stdio(false); -#endif - solve(); - return 0; -} // }}} From 127089c57f439a3634ef91c5c94aaffdd1152db7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:18:34 -0500 Subject: [PATCH 079/114] fix(submit): harden atcoder and codeforces submit flow (#304) ## Problem AtCoder file upload always wrote a `.cpp` temp file regardless of language. CF submit used `solve_cloudflare=True` on the submit page, causing a spurious "No Cloudflare challenge found" error; `_wait_for_gate_reload` in `login_action` was dead code. Stale cookies caused silent auth failures with no recovery path. The `uv.spawn` ndjson path for submit had no overall timeout. ## Solution Replace AtCoder's temp file with `page.set_input_files` using an in-memory buffer and correct extension via `_LANGUAGE_ID_EXTENSION`. Replace CF's temp-file/fallback dance with a direct `textarea[name="source"]` fill and set `solve_cloudflare=False` on the submit fetch. Add a login fast-path that skips the homepage check when cookies exist, with automatic stale-cookie recovery via `_retried` flag on redirect-to-login detection. Remove `_wait_for_gate_reload`. Fix `_ensure_browser` to propagate install errors. Add a 120s kill timer to the ndjson `uv.spawn` submit path in `scraper.lua`. --- lua/cp/scraper.lua | 29 +++++++++++++ scrapers/atcoder.py | 98 ++++++++++++++++++++++++------------------ scrapers/codeforces.py | 91 ++++++++++++++++----------------------- 3 files changed, 122 insertions(+), 96 deletions(-) diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index fc8ba69..6980c7d 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -85,6 +85,7 @@ local function run_scraper(platform, subcommand, args, opts) local stderr = uv.new_pipe(false) local buf = '' + local timer = nil local handle handle = uv.spawn(cmd[1], { args = vim.list_slice(cmd, 2), @@ -92,6 +93,10 @@ local function run_scraper(platform, subcommand, args, opts) env = spawn_env_list(env), cwd = plugin_path, }, function(code, signal) + if timer and not timer:is_closing() then + timer:stop() + timer:close() + end if buf ~= '' and opts.on_event then local ok_tail, ev_tail = pcall(vim.json.decode, buf) if ok_tail then @@ -124,6 +129,30 @@ local function run_scraper(platform, subcommand, args, opts) return { success = false, error = 'spawn failed' } end + if subcommand == 'submit' then + timer = uv.new_timer() + timer:start(120000, 0, function() + timer:stop() + timer:close() + if stdin_pipe and not stdin_pipe:is_closing() then + stdin_pipe:close() + end + if not stdout:is_closing() then + stdout:close() + end + if not stderr:is_closing() then + stderr:close() + end + if handle and not handle:is_closing() then + handle:kill(15) + handle:close() + end + if opts.on_exit then + opts.on_exit({ success = false, error = 'submit timed out' }) + end + end) + end + if stdin_pipe then uv.write(stdin_pipe, opts.stdin, function() uv.shutdown(stdin_pipe, function() diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 45a2195..8b3db72 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -6,7 +6,6 @@ import os import re import subprocess import sys -import tempfile import time from typing import Any @@ -38,6 +37,11 @@ from .timeouts import ( HTTP_TIMEOUT, ) +_LANGUAGE_ID_EXTENSION = { + "6017": "cc", + "6082": "py", +} + MIB_TO_MB = 1.048576 BASE_URL = "https://atcoder.jp" ARCHIVE_URL = f"{BASE_URL}/contests/archive" @@ -274,23 +278,20 @@ def _ensure_browser() -> None: from patchright._impl._driver import compute_driver_executable # type: ignore[import-untyped,unresolved-import] node, cli = compute_driver_executable() - browser_info = subprocess.run( - [node, cli, "install", "--dry-run", "chromium"], - capture_output=True, - text=True, - ) - for line in browser_info.stdout.splitlines(): - if "Install location:" in line: - install_dir = line.split(":", 1)[1].strip() - if not os.path.isdir(install_dir): - print(json.dumps({"status": "installing_browser"}), flush=True) - subprocess.run( - [node, cli, "install", "chromium"], - check=True, - ) - break except Exception: - pass + return + browser_info = subprocess.run( + [node, cli, "install", "--dry-run", "chromium"], + capture_output=True, + text=True, + ) + for line in browser_info.stdout.splitlines(): + if "Install location:" in line: + install_dir = line.split(":", 1)[1].strip() + if not os.path.isdir(install_dir): + print(json.dumps({"status": "installing_browser"}), flush=True) + subprocess.run([node, cli, "install", "chromium"], check=True) + break def _submit_headless( @@ -299,6 +300,7 @@ def _submit_headless( source_code: str, language_id: str, credentials: dict[str, str], + _retried: bool = False, ) -> "SubmitResult": from pathlib import Path @@ -321,9 +323,10 @@ def _submit_headless( except Exception: pass - logged_in = False + logged_in = cookie_cache.exists() and not _retried login_error: str | None = None submit_error: str | None = None + needs_relogin = False def check_login(page): nonlocal logged_in @@ -345,7 +348,10 @@ def _submit_headless( login_error = str(e) def submit_action(page): - nonlocal submit_error + nonlocal submit_error, needs_relogin + if "/login" in page.url: + needs_relogin = True + return try: _solve_turnstile(page) page.select_option( @@ -356,16 +362,16 @@ def _submit_headless( f'select[name="data.LanguageId"] option[value="{language_id}"]' ).wait_for(state="attached", timeout=BROWSER_ELEMENT_WAIT) page.select_option('select[name="data.LanguageId"]', language_id) - with tempfile.NamedTemporaryFile( - mode="w", suffix=".cpp", delete=False, prefix="atcoder_" - ) as tf: - tf.write(source_code) - tmp_path = tf.name - try: - page.set_input_files("#input-open-file", tmp_path) - page.wait_for_timeout(BROWSER_SETTLE_DELAY) - finally: - os.unlink(tmp_path) + ext = _LANGUAGE_ID_EXTENSION.get(language_id, "txt") + page.set_input_files( + "#input-open-file", + { + "name": f"solution.{ext}", + "mimeType": "text/plain", + "buffer": source_code.encode(), + }, + ) + page.wait_for_timeout(BROWSER_SETTLE_DELAY) page.locator('button[type="submit"]').click() page.wait_for_url( lambda url: "/submissions/me" in url, timeout=BROWSER_NAV_TIMEOUT @@ -378,14 +384,13 @@ def _submit_headless( headless=True, timeout=BROWSER_SESSION_TIMEOUT, google_search=False, - cookies=saved_cookies, + cookies=saved_cookies if (cookie_cache.exists() and not _retried) else [], ) as session: - print(json.dumps({"status": "checking_login"}), flush=True) - session.fetch( - f"{BASE_URL}/home", - page_action=check_login, - network_idle=True, - ) + if not (cookie_cache.exists() and not _retried): + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/home", page_action=check_login, network_idle=True + ) if not logged_in: print(json.dumps({"status": "logging_in"}), flush=True) @@ -413,12 +418,23 @@ def _submit_headless( except Exception: pass - if submit_error: - return SubmitResult(success=False, error=submit_error) - - return SubmitResult( - success=True, error="", submission_id="", verdict="submitted" + if needs_relogin and not _retried: + cookie_cache.unlink(missing_ok=True) + return _submit_headless( + contest_id, + problem_id, + source_code, + language_id, + credentials, + _retried=True, ) + + if submit_error: + return SubmitResult(success=False, error=submit_error) + + return SubmitResult( + success=True, error="", submission_id="", verdict="submitted" + ) except Exception as e: return SubmitResult(success=False, error=str(e)) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index fd0c129..d2c3164 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -2,9 +2,7 @@ import asyncio import json -import os import re -import tempfile from typing import Any import requests @@ -21,10 +19,8 @@ from .models import ( TestCase, ) from .timeouts import ( - BROWSER_ELEMENT_WAIT, BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, - BROWSER_SETTLE_DELAY, HTTP_TIMEOUT, ) @@ -307,24 +303,13 @@ class CodeforcesScraper(BaseScraper): ) -def _wait_for_gate_reload(page, wait_selector: str) -> None: - from .atcoder import _solve_turnstile - - if "Verification" not in page.title(): - return - _solve_turnstile(page) - page.wait_for_function( - f"() => !!document.querySelector('{wait_selector}')", - timeout=BROWSER_ELEMENT_WAIT, - ) - - def _submit_headless( contest_id: str, problem_id: str, source_code: str, language_id: str, credentials: dict[str, str], + _retried: bool = False, ) -> SubmitResult: from pathlib import Path @@ -349,9 +334,10 @@ def _submit_headless( except Exception: pass - logged_in = False + logged_in = cookie_cache.exists() and not _retried login_error: str | None = None submit_error: str | None = None + needs_relogin = False def check_login(page): nonlocal logged_in @@ -362,10 +348,6 @@ def _submit_headless( def login_action(page): nonlocal login_error - try: - _wait_for_gate_reload(page, "#enterForm") - except Exception: - pass try: page.fill( 'input[name="handleOrEmail"]', @@ -383,28 +365,21 @@ def _submit_headless( login_error = str(e) def submit_action(page): - nonlocal submit_error + nonlocal submit_error, needs_relogin + if "/enter" in page.url or "/login" in page.url: + needs_relogin = True + return try: _solve_turnstile(page) except Exception: pass - tmp_path: str | None = None try: page.select_option( 'select[name="submittedProblemIndex"]', problem_id.upper(), ) page.select_option('select[name="programTypeId"]', language_id) - with tempfile.NamedTemporaryFile( - mode="w", suffix=".cpp", delete=False, prefix="cf_" - ) as tf: - tf.write(source_code) - tmp_path = tf.name - try: - page.set_input_files('input[name="sourceFile"]', tmp_path) - page.wait_for_timeout(BROWSER_SETTLE_DELAY) - except Exception: - page.fill('textarea[name="source"]', source_code) + page.fill('textarea[name="source"]', source_code) page.locator("form.submit-form input.submit").click(no_wait_after=True) try: page.wait_for_url( @@ -419,26 +394,21 @@ def _submit_headless( submit_error = "Submit failed: page did not navigate" except Exception as e: submit_error = str(e) - finally: - if tmp_path: - try: - os.unlink(tmp_path) - except OSError: - pass try: with StealthySession( headless=True, timeout=BROWSER_SESSION_TIMEOUT, google_search=False, - cookies=saved_cookies, + cookies=saved_cookies if (cookie_cache.exists() and not _retried) else [], ) as session: - print(json.dumps({"status": "checking_login"}), flush=True) - session.fetch( - f"{BASE_URL}/", - page_action=check_login, - network_idle=True, - ) + if not (cookie_cache.exists() and not _retried): + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/", + page_action=check_login, + network_idle=True, + ) if not logged_in: print(json.dumps({"status": "logging_in"}), flush=True) @@ -456,7 +426,7 @@ def _submit_headless( session.fetch( f"{BASE_URL}/contest/{contest_id}/submit", page_action=submit_action, - solve_cloudflare=True, + solve_cloudflare=False, ) try: @@ -466,15 +436,26 @@ def _submit_headless( except Exception: pass - if submit_error: - return SubmitResult(success=False, error=submit_error) - - return SubmitResult( - success=True, - error="", - submission_id="", - verdict="submitted", + if needs_relogin and not _retried: + cookie_cache.unlink(missing_ok=True) + return _submit_headless( + contest_id, + problem_id, + source_code, + language_id, + credentials, + _retried=True, ) + + if submit_error: + return SubmitResult(success=False, error=submit_error) + + return SubmitResult( + success=True, + error="", + submission_id="", + verdict="submitted", + ) except Exception as e: return SubmitResult(success=False, error=str(e)) From a202725cc559b1b9068151fb3bcb0d3bd15acdef Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:34:14 -0500 Subject: [PATCH 080/114] fix(submit): use file path over stdin; fix CF CodeMirror textarea (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem After the initial submit hardening, two issues remained: source code was read in Lua and piped as stdin to the scraper (unnecessary roundtrip since the file exists on disk), and CF's `page.fill()` timed out on the hidden `textarea[name="source"]` because CodeMirror owns the editor state. ## Solution Pass the source file path as a CLI arg instead — AtCoder calls `page.set_input_files(file_path)` directly, CF reads it with `Path(file_path).read_text()`. Fix CF source injection via `page.evaluate()` into the CodeMirror instance. Extract `BROWSER_SUBMIT_NAV_TIMEOUT` as a per-platform `defaultdict` (CF defaults to 2× nav timeout). Save the buffer with `vim.cmd.update()` before submitting. --- lua/cp/commands/cache.lua | 18 ++++++++----- lua/cp/commands/init.lua | 11 ++++---- lua/cp/commands/picker.lua | 10 +++---- lua/cp/constants.lua | 2 -- lua/cp/credentials.lua | 18 ++++++++----- lua/cp/init.lua | 2 +- lua/cp/log.lua | 23 ++++++++++++++--- lua/cp/pickers/init.lua | 14 ++++------ lua/cp/race.lua | 24 +++++++++-------- lua/cp/restore.lua | 2 +- lua/cp/runner/execute.lua | 2 +- lua/cp/runner/run.lua | 10 +++---- lua/cp/scraper.lua | 27 +++++++++++-------- lua/cp/setup.lua | 53 +++++++++++++++++++++++++++++++------- lua/cp/stress.lua | 10 +++---- lua/cp/submit.lua | 28 ++++++++++---------- lua/cp/ui/edit.lua | 16 ++++++++---- lua/cp/ui/views.lua | 52 +++++++++++++++++++++++-------------- lua/cp/utils.lua | 18 ++++++++----- plugin/cp.lua | 8 +++--- scrapers/atcoder.py | 29 ++++++++------------- scrapers/base.py | 9 +++---- scrapers/codechef.py | 4 +-- scrapers/codeforces.py | 25 +++++++++++++----- scrapers/cses.py | 5 +++- scrapers/kattis.py | 2 +- scrapers/timeouts.py | 6 +++++ scrapers/usaco.py | 9 ++++--- 28 files changed, 269 insertions(+), 168 deletions(-) diff --git a/lua/cp/commands/cache.lua b/lua/cp/commands/cache.lua index aba8bf5..cec3eba 100644 --- a/lua/cp/commands/cache.lua +++ b/lua/cp/commands/cache.lua @@ -47,26 +47,30 @@ function M.handle_cache_command(cmd) constants.PLATFORM_DISPLAY_NAMES[cmd.platform], cmd.contest ), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) else - logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR) + logger.log( + ("Unknown platform '%s'."):format(cmd.platform), + { level = vim.log.levels.ERROR } + ) end elseif cmd.platform then if vim.tbl_contains(platforms, cmd.platform) then cache.clear_platform(cmd.platform) logger.log( ("Cache cleared for platform '%s'"):format(constants.PLATFORM_DISPLAY_NAMES[cmd.platform]), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) else - logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR) + logger.log( + ("Unknown platform '%s'."):format(cmd.platform), + { level = vim.log.levels.ERROR } + ) end else cache.clear_all() - logger.log('Cache cleared', vim.log.levels.INFO, true) + logger.log('Cache cleared', { level = vim.log.levels.INFO, override = true }) end end end diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 56a473f..4c594bd 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -83,8 +83,6 @@ local function parse_command(args) else return { type = 'action', action = 'interact' } end - elseif first == 'login' or first == 'logout' then - return { type = 'action', action = first, platform = args[2] } elseif first == 'stress' then return { type = 'action', @@ -245,6 +243,9 @@ local function parse_command(args) message = 'Too few arguments - specify a contest.', } elseif #args == 2 then + if args[2] == 'login' or args[2] == 'logout' then + return { type = 'action', action = args[2], platform = first } + end return { type = 'contest_setup', platform = first, @@ -287,7 +288,7 @@ function M.handle_command(opts) local cmd = parse_command(opts.fargs) if cmd.type == 'error' then - logger.log(cmd.message, vim.log.levels.ERROR) + logger.log(cmd.message, { level = vim.log.levels.ERROR }) return end @@ -336,7 +337,7 @@ function M.handle_command(opts) local problem_id = cmd.problem_id if not (platform and contest_id) then - logger.log('No contest is currently active.', vim.log.levels.ERROR) + logger.log('No contest is currently active.', { level = vim.log.levels.ERROR }) return end @@ -351,7 +352,7 @@ function M.handle_command(opts) contest_id, problem_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end diff --git a/lua/cp/commands/picker.lua b/lua/cp/commands/picker.lua index dc49bea..e01b680 100644 --- a/lua/cp/commands/picker.lua +++ b/lua/cp/commands/picker.lua @@ -12,7 +12,7 @@ function M.handle_pick_action(language) if not (config.ui and config.ui.picker) then logger.log( 'No picker configured. Set ui.picker = "{telescope,fzf-lua}" in your config.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -25,13 +25,13 @@ function M.handle_pick_action(language) if not ok then logger.log( 'telescope.nvim is not available. Install telescope.nvim xor change your picker config.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end local ok_cp, telescope_picker = pcall(require, 'cp.pickers.telescope') if not ok_cp then - logger.log('Failed to load telescope integration.', vim.log.levels.ERROR) + logger.log('Failed to load telescope integration.', { level = vim.log.levels.ERROR }) return end @@ -41,13 +41,13 @@ function M.handle_pick_action(language) if not ok then logger.log( 'fzf-lua is not available. Install fzf-lua or change your picker config', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end local ok_cp, fzf_picker = pcall(require, 'cp.pickers.fzf_lua') if not ok_cp then - logger.log('Failed to load fzf-lua integration.', vim.log.levels.ERROR) + logger.log('Failed to load fzf-lua integration.', { level = vim.log.levels.ERROR }) return end diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 5d1c3c5..21e8f62 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -13,8 +13,6 @@ M.ACTIONS = { 'race', 'stress', 'submit', - 'login', - 'logout', } M.PLATFORM_DISPLAY_NAMES = { diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index 8f80743..76945b6 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -7,37 +7,43 @@ local state = require('cp.state') function M.login(platform) platform = platform or state.get_platform() if not platform then - logger.log('No platform specified. Usage: :CP login ', vim.log.levels.ERROR) + logger.log( + 'No platform specified. Usage: :CP login ', + { level = vim.log.levels.ERROR } + ) return end vim.ui.input({ prompt = platform .. ' username: ' }, function(username) if not username or username == '' then - logger.log('Cancelled', vim.log.levels.WARN) + logger.log('Cancelled', { level = vim.log.levels.WARN }) return end vim.fn.inputsave() local password = vim.fn.inputsecret(platform .. ' password: ') vim.fn.inputrestore() if not password or password == '' then - logger.log('Cancelled', vim.log.levels.WARN) + logger.log('Cancelled', { level = vim.log.levels.WARN }) return end cache.load() cache.set_credentials(platform, { username = username, password = password }) - logger.log(platform .. ' credentials saved', vim.log.levels.INFO, true) + logger.log(platform .. ' credentials saved', { level = vim.log.levels.INFO, override = true }) end) end function M.logout(platform) platform = platform or state.get_platform() if not platform then - logger.log('No platform specified. Usage: :CP logout ', vim.log.levels.ERROR) + logger.log( + 'No platform specified. Usage: :CP logout ', + { level = vim.log.levels.ERROR } + ) return end cache.load() cache.clear_credentials(platform) - logger.log(platform .. ' credentials cleared', vim.log.levels.INFO, true) + logger.log(platform .. ' credentials cleared', { level = vim.log.levels.INFO, override = true }) end return M diff --git a/lua/cp/init.lua b/lua/cp/init.lua index 088272a..4ffb530 100644 --- a/lua/cp/init.lua +++ b/lua/cp/init.lua @@ -7,7 +7,7 @@ local logger = require('cp.log') M.helpers = helpers if vim.fn.has('nvim-0.10.0') == 0 then - logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR) + logger.log('Requires nvim-0.10.0+', { level = vim.log.levels.ERROR }) return {} end diff --git a/lua/cp/log.lua b/lua/cp/log.lua index 02bc5f4..74a8859 100644 --- a/lua/cp/log.lua +++ b/lua/cp/log.lua @@ -1,12 +1,27 @@ local M = {} -function M.log(msg, level, override) +---@class LogOpts +---@field level? integer +---@field override? boolean +---@field sync? boolean + +---@param msg string +---@param opts? LogOpts +function M.log(msg, opts) local debug = require('cp.config').get_config().debug or false - level = level or vim.log.levels.INFO + opts = opts or {} + local level = opts.level or vim.log.levels.INFO + local override = opts.override or false + local sync = opts.sync or false if level >= vim.log.levels.WARN or override or debug then - vim.schedule(function() + local notify = function() vim.notify(('[cp.nvim]: %s'):format(msg), level) - end) + end + if sync then + notify() + else + vim.schedule(notify) + end end end diff --git a/lua/cp/pickers/init.lua b/lua/cp/pickers/init.lua index c634c64..562c274 100644 --- a/lua/cp/pickers/init.lua +++ b/lua/cp/pickers/init.lua @@ -42,10 +42,10 @@ function M.get_platform_contests(platform, refresh) local picker_contests = cache.get_contest_summaries(platform) if refresh or vim.tbl_isempty(picker_contests) then + local display_name = constants.PLATFORM_DISPLAY_NAMES[platform] logger.log( - ('Loading %s contests...'):format(constants.PLATFORM_DISPLAY_NAMES[platform]), - vim.log.levels.INFO, - true + ('Fetching %s contests...'):format(display_name), + { level = vim.log.levels.INFO, override = true, sync = true } ) local contests = scraper.scrape_contest_list(platform) @@ -53,12 +53,8 @@ function M.get_platform_contests(platform, refresh) picker_contests = cache.get_contest_summaries(platform) logger.log( - ('Loaded %d %s contests.'):format( - #picker_contests, - constants.PLATFORM_DISPLAY_NAMES[platform] - ), - vim.log.levels.INFO, - true + ('Fetched %d %s contests.'):format(#picker_contests, display_name), + { level = vim.log.levels.INFO, override = true } ) end diff --git a/lua/cp/race.lua b/lua/cp/race.lua index 0e303ee..b44b9ab 100644 --- a/lua/cp/race.lua +++ b/lua/cp/race.lua @@ -22,15 +22,15 @@ end function M.start(platform, contest_id, language) if not platform or not vim.tbl_contains(constants.PLATFORMS, platform) then - logger.log('Invalid platform', vim.log.levels.ERROR) + logger.log('Invalid platform', { level = vim.log.levels.ERROR }) return end if not contest_id or contest_id == '' then - logger.log('Contest ID required', vim.log.levels.ERROR) + logger.log('Contest ID required', { level = vim.log.levels.ERROR }) return end if race_state.timer then - logger.log('Race already active. Use :CP race stop first.', vim.log.levels.WARN) + logger.log('Race already active. Use :CP race stop first.', { level = vim.log.levels.WARN }) return end @@ -38,7 +38,7 @@ function M.start(platform, contest_id, language) local start_time = cache.get_contest_start_time(platform, contest_id) if not start_time then - logger.log('Fetching contest list...', vim.log.levels.INFO, true) + logger.log('Fetching contest list...', { level = vim.log.levels.INFO, override = true }) local contests = scraper.scrape_contest_list(platform) if contests and #contests > 0 then cache.set_contest_summaries(platform, contests) @@ -52,14 +52,17 @@ function M.start(platform, contest_id, language) constants.PLATFORM_DISPLAY_NAMES[platform] or platform, contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end local remaining = start_time - os.time() if remaining <= 0 then - logger.log('Contest has already started, setting up...', vim.log.levels.INFO, true) + logger.log( + 'Contest has already started, setting up...', + { level = vim.log.levels.INFO, override = true } + ) require('cp.setup').setup_contest(platform, contest_id, nil, language) return end @@ -75,8 +78,7 @@ function M.start(platform, contest_id, language) contest_id, format_countdown(remaining) ), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) local timer = vim.uv.new_timer() @@ -97,7 +99,7 @@ function M.start(platform, contest_id, language) race_state.contest_id = nil race_state.language = nil race_state.start_time = nil - logger.log('Contest started!', vim.log.levels.INFO, true) + logger.log('Contest started!', { level = vim.log.levels.INFO, override = true }) require('cp.setup').setup_contest(p, c, nil, l) else vim.notify( @@ -116,7 +118,7 @@ end function M.stop() local timer = race_state.timer if not timer then - logger.log('No active race', vim.log.levels.WARN) + logger.log('No active race', { level = vim.log.levels.WARN }) return end timer:stop() @@ -126,7 +128,7 @@ function M.stop() race_state.contest_id = nil race_state.language = nil race_state.start_time = nil - logger.log('Race cancelled', vim.log.levels.INFO, true) + logger.log('Race cancelled', { level = vim.log.levels.INFO, override = true }) end function M.status() diff --git a/lua/cp/restore.lua b/lua/cp/restore.lua index fde3fd5..8b9eeab 100644 --- a/lua/cp/restore.lua +++ b/lua/cp/restore.lua @@ -11,7 +11,7 @@ function M.restore_from_current_file() local current_file = (vim.uv.fs_realpath(vim.fn.expand('%:p')) or vim.fn.expand('%:p')) local file_state = cache.get_file_state(current_file) if not file_state then - logger.log('No cached state found for current file.', vim.log.levels.ERROR) + logger.log('No cached state found for current file.', { level = vim.log.levels.ERROR }) return false end diff --git a/lua/cp/runner/execute.lua b/lua/cp/runner/execute.lua index 9e40b4f..41a7b51 100644 --- a/lua/cp/runner/execute.lua +++ b/lua/cp/runner/execute.lua @@ -52,7 +52,7 @@ function M.compile(compile_cmd, substitutions, on_complete) r.stdout = ansi.bytes_to_string(r.stdout or '') if r.code == 0 then - logger.log(('Compilation successful in %.1fms.'):format(dt), vim.log.levels.INFO) + logger.log(('Compilation successful in %.1fms.'):format(dt), { level = vim.log.levels.INFO }) else logger.log(('Compilation failed in %.1fms.'):format(dt)) end diff --git a/lua/cp/runner/run.lua b/lua/cp/runner/run.lua index 306a1f8..2b374b0 100644 --- a/lua/cp/runner/run.lua +++ b/lua/cp/runner/run.lua @@ -245,7 +245,7 @@ function M.load_test_cases() state.get_problem_id() ) - logger.log(('Loaded %d test case(s)'):format(#tcs), vim.log.levels.INFO) + logger.log(('Loaded %d test case(s)'):format(#tcs), { level = vim.log.levels.INFO }) return #tcs > 0 end @@ -259,7 +259,7 @@ function M.run_combined_test(debug, on_complete) ) if not combined then - logger.log('No combined test found', vim.log.levels.ERROR) + logger.log('No combined test found', { level = vim.log.levels.ERROR }) on_complete(nil) return end @@ -330,8 +330,7 @@ function M.run_all_test_cases(indices, debug, on_each, on_done) if #to_run == 0 then logger.log( ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', 0), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) on_done(panel_state.test_cases) return @@ -349,8 +348,7 @@ function M.run_all_test_cases(indices, debug, on_each, on_done) if remaining == 0 then logger.log( ('Finished %s %d test cases.'):format(debug and 'debugging' or 'running', total), - vim.log.levels.INFO, - true + { level = vim.log.levels.INFO, override = true } ) on_done(panel_state.test_cases) end diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 6980c7d..af170d3 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -16,7 +16,7 @@ local function syshandle(result) end local msg = 'Failed to parse scraper output: ' .. tostring(data) - logger.log(msg, vim.log.levels.ERROR) + logger.log(msg, { level = vim.log.levels.ERROR }) return { success = false, error = msg } end @@ -37,7 +37,7 @@ end local function run_scraper(platform, subcommand, args, opts) if not utils.setup_python_env() then local msg = 'no Python environment available (install uv or nix)' - logger.log(msg, vim.log.levels.ERROR) + logger.log(msg, { level = vim.log.levels.ERROR }) if opts and opts.on_exit then opts.on_exit({ success = false, error = msg }) end @@ -125,7 +125,7 @@ local function run_scraper(platform, subcommand, args, opts) if stdin_pipe and not stdin_pipe:is_closing() then stdin_pipe:close() end - logger.log('Failed to start scraper process', vim.log.levels.ERROR) + logger.log('Failed to start scraper process', { level = vim.log.levels.ERROR }) return { success = false, error = 'spawn failed' } end @@ -221,7 +221,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback) constants.PLATFORM_DISPLAY_NAMES[platform], contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -232,7 +232,7 @@ function M.scrape_contest_metadata(platform, contest_id, callback) constants.PLATFORM_DISPLAY_NAMES[platform], contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -251,7 +251,7 @@ function M.scrape_contest_list(platform) platform, (result and result.error) or 'unknown' ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return {} end @@ -261,9 +261,15 @@ end ---@param platform string ---@param contest_id string ---@param callback fun(data: table)|nil -function M.scrape_all_tests(platform, contest_id, callback) +---@param on_done fun()|nil +function M.scrape_all_tests(platform, contest_id, callback, on_done) run_scraper(platform, 'tests', { contest_id }, { ndjson = true, + on_exit = function() + if type(on_done) == 'function' then + vim.schedule(on_done) + end + end, on_event = function(ev) if ev.done then return @@ -275,7 +281,7 @@ function M.scrape_all_tests(platform, contest_id, callback) contest_id, ev.error ), - vim.log.levels.WARN + { level = vim.log.levels.WARN } ) return end @@ -316,15 +322,14 @@ function M.submit( contest_id, problem_id, language, - source_code, + source_file, credentials, on_status, callback ) local done = false - run_scraper(platform, 'submit', { contest_id, problem_id, language }, { + run_scraper(platform, 'submit', { contest_id, problem_id, language, source_file }, { ndjson = true, - stdin = source_code, env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) }, on_event = function(ev) if ev.credentials ~= nil then diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 19fa776..eb7ba8c 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -16,7 +16,10 @@ local function apply_template(bufnr, lang_id, platform) end local path = vim.fn.expand(eff.template) if vim.fn.filereadable(path) ~= 1 then - logger.log(('[cp.nvim] template not readable: %s'):format(path), vim.log.levels.WARN) + logger.log( + ('[cp.nvim] template not readable: %s'):format(path), + { level = vim.log.levels.WARN } + ) return end local lines = vim.fn.readfile(path) @@ -112,11 +115,15 @@ local function start_tests(platform, contest_id, problems) return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id)) end, problems) if cached_len ~= #problems then + local to_fetch = #problems - cached_len logger.log(('Fetching %s/%s problem tests...'):format(cached_len, #problems)) scraper.scrape_all_tests(platform, contest_id, function(ev) local cached_tests = {} if not ev.interactive and vim.tbl_isempty(ev.tests) then - logger.log(("No tests found for problem '%s'."):format(ev.problem_id), vim.log.levels.WARN) + logger.log( + ("No tests found for problem '%s'."):format(ev.problem_id), + { level = vim.log.levels.WARN } + ) end for i, t in ipairs(ev.tests) do cached_tests[i] = { index = i, input = t.input, expected = t.expected } @@ -142,6 +149,11 @@ local function start_tests(platform, contest_id, problems) require('cp.utils').update_buffer_content(io_state.input_buf, input_lines, nil, nil) end end + end, function() + logger.log( + ('Loaded %d test%s.'):format(to_fetch, to_fetch == 1 and '' or 's'), + { level = vim.log.levels.INFO, override = true } + ) end) end end @@ -160,7 +172,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) if language then local lang_result = config_module.get_language_for_platform(platform, language) if not lang_result.valid then - logger.log(lang_result.error, vim.log.levels.ERROR) + logger.log(lang_result.error, { level = vim.log.levels.ERROR }) return end end @@ -206,7 +218,7 @@ function M.setup_contest(platform, contest_id, problem_id, language) token = vim.uv.hrtime(), }) - logger.log('Fetching contests problems...', vim.log.levels.INFO, true) + logger.log('Fetching contests problems...', { level = vim.log.levels.INFO, override = true }) scraper.scrape_contest_metadata( platform, contest_id, @@ -242,7 +254,7 @@ end function M.setup_problem(problem_id, language) local platform = state.get_platform() if not platform then - logger.log('No platform/contest/problem configured.', vim.log.levels.ERROR) + logger.log('No platform/contest/problem configured.', { level = vim.log.levels.ERROR }) return end @@ -263,7 +275,7 @@ function M.setup_problem(problem_id, language) if language then local lang_result = config_module.get_language_for_platform(platform, language) if not lang_result.valid then - logger.log(lang_result.error, vim.log.levels.ERROR) + logger.log(lang_result.error, { level = vim.log.levels.ERROR }) return end end @@ -275,6 +287,29 @@ function M.setup_problem(problem_id, language) return end + if vim.fn.filereadable(source_file) == 1 then + local existing = cache.get_file_state(vim.fn.fnamemodify(source_file, ':p')) + if + existing + and ( + existing.platform ~= platform + or existing.contest_id ~= (state.get_contest_id() or '') + or existing.problem_id ~= problem_id + ) + then + logger.log( + ('File %q already exists for %s/%s %s.'):format( + source_file, + existing.platform, + existing.contest_id, + existing.problem_id + ), + { level = vim.log.levels.ERROR } + ) + return + end + end + local contest_dir = vim.fn.fnamemodify(source_file, ':h') local is_new_dir = vim.fn.isdirectory(contest_dir) == 0 vim.fn.mkdir(contest_dir, 'p') @@ -397,7 +432,7 @@ function M.navigate_problem(direction, language) local contest_id = state.get_contest_id() local current_problem_id = state.get_problem_id() if not platform or not contest_id or not current_problem_id then - logger.log('No platform configured.', vim.log.levels.ERROR) + logger.log('No platform configured.', { level = vim.log.levels.ERROR }) return end @@ -409,7 +444,7 @@ function M.navigate_problem(direction, language) constants.PLATFORM_DISPLAY_NAMES[platform], contest_id ), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -433,7 +468,7 @@ function M.navigate_problem(direction, language) if language then local lang_result = config_module.get_language_for_platform(platform, language) if not lang_result.valid then - logger.log(lang_result.error, vim.log.levels.ERROR) + logger.log(lang_result.error, { level = vim.log.levels.ERROR }) return end lang = language diff --git a/lua/cp/stress.lua b/lua/cp/stress.lua index 3e51881..9063d0d 100644 --- a/lua/cp/stress.lua +++ b/lua/cp/stress.lua @@ -36,7 +36,7 @@ local function compile_cpp(source, output) if result.code ~= 0 then logger.log( ('Failed to compile %s: %s'):format(source, result.stderr or ''), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return false end @@ -76,7 +76,7 @@ function M.toggle(generator_cmd, brute_cmd) end if state.get_active_panel() then - logger.log('Another panel is already active.', vim.log.levels.WARN) + logger.log('Another panel is already active.', { level = vim.log.levels.WARN }) return end @@ -93,14 +93,14 @@ function M.toggle(generator_cmd, brute_cmd) if not gen_file then logger.log( 'No generator found. Pass generator as first arg or add gen.{py,cc,cpp}.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end if not brute_file then logger.log( 'No brute solution found. Pass brute as second arg or add brute.{py,cc,cpp}.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -140,7 +140,7 @@ function M.toggle(generator_cmd, brute_cmd) local binary = state.get_binary_file() if not binary or binary == '' then - logger.log('No binary produced.', vim.log.levels.ERROR) + logger.log('No binary produced.', { level = vim.log.levels.ERROR }) restore_session() return end diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 7dc9a71..77e3d66 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -19,7 +19,7 @@ local function prompt_credentials(platform, callback) end vim.ui.input({ prompt = platform .. ' username: ' }, function(username) if not username or username == '' then - logger.log('Submit cancelled', vim.log.levels.WARN) + logger.log('Submit cancelled', { level = vim.log.levels.WARN }) return end vim.fn.inputsave() @@ -27,7 +27,7 @@ local function prompt_credentials(platform, callback) vim.fn.inputrestore() vim.cmd.redraw() if not password or password == '' then - logger.log('Submit cancelled', vim.log.levels.WARN) + logger.log('Submit cancelled', { level = vim.log.levels.WARN }) return end local creds = { username = username, password = password } @@ -42,20 +42,21 @@ function M.submit(opts) local problem_id = state.get_problem_id() local language = (opts and opts.language) or state.get_language() if not platform or not contest_id or not problem_id or not language then - logger.log('No active problem. Use :CP first.', vim.log.levels.ERROR) + logger.log( + 'No active problem. Use :CP first.', + { level = vim.log.levels.ERROR } + ) return end local source_file = state.get_source_file() if not source_file or vim.fn.filereadable(source_file) ~= 1 then - logger.log('Source file not found', vim.log.levels.ERROR) + logger.log('Source file not found', { level = vim.log.levels.ERROR }) return end prompt_credentials(platform, function(creds) - local source_lines = vim.fn.readfile(source_file) - local source_code = table.concat(source_lines, '\n') - + vim.cmd.update() vim.notify('[cp.nvim] Submitting...', vim.log.levels.INFO) require('cp.scraper').submit( @@ -63,7 +64,7 @@ function M.submit(opts) contest_id, problem_id, language, - source_code, + source_file, creds, function(ev) vim.schedule(function() @@ -73,12 +74,13 @@ function M.submit(opts) function(result) vim.schedule(function() if result and result.success then - logger.log('Submitted successfully', vim.log.levels.INFO, true) + logger.log('Submitted successfully', { level = vim.log.levels.INFO, override = true }) else - logger.log( - 'Submit failed: ' .. (result and result.error or 'unknown error'), - vim.log.levels.ERROR - ) + local err = result and result.error or 'unknown error' + if err:match('^Login failed') then + cache.clear_credentials(platform) + end + logger.log('Submit failed: ' .. err, { level = vim.log.levels.ERROR }) end end) end diff --git a/lua/cp/ui/edit.lua b/lua/cp/ui/edit.lua index 20d4e83..93083aa 100644 --- a/lua/cp/ui/edit.lua +++ b/lua/cp/ui/edit.lua @@ -90,7 +90,7 @@ local function delete_current_test() return end if #edit_state.test_buffers == 1 then - logger.log('Problems must have at least one test case.', vim.log.levels.ERROR) + logger.log('Problems must have at least one test case.', { level = vim.log.levels.ERROR }) return end @@ -311,7 +311,10 @@ setup_keybindings = function(buf) end if is_tracked then - logger.log('Test buffer closed unexpectedly. Exiting editor.', vim.log.levels.WARN) + logger.log( + 'Test buffer closed unexpectedly. Exiting editor.', + { level = vim.log.levels.WARN } + ) M.toggle_edit() end end) @@ -368,7 +371,10 @@ function M.toggle_edit(test_index) state.get_platform(), state.get_contest_id(), state.get_problem_id() if not platform or not contest_id or not problem_id then - logger.log('No problem context. Run :CP first.', vim.log.levels.ERROR) + logger.log( + 'No problem context. Run :CP first.', + { level = vim.log.levels.ERROR } + ) return end @@ -376,7 +382,7 @@ function M.toggle_edit(test_index) local test_cases = cache.get_test_cases(platform, contest_id, problem_id) if not test_cases or #test_cases == 0 then - logger.log('No test cases available for editing.', vim.log.levels.ERROR) + logger.log('No test cases available for editing.', { level = vim.log.levels.ERROR }) return end @@ -389,7 +395,7 @@ function M.toggle_edit(test_index) if target_index < 1 or target_index > #test_cases then logger.log( ('Test %d does not exist (only %d tests available)'):format(target_index, #test_cases), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index 5e658c4..b2ee23d 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -53,7 +53,7 @@ function M.toggle_interactive(interactor_cmd) end if state.get_active_panel() then - logger.log('Another panel is already active.', vim.log.levels.WARN) + logger.log('Another panel is already active.', { level = vim.log.levels.WARN }) return end @@ -62,7 +62,7 @@ function M.toggle_interactive(interactor_cmd) if not platform or not contest_id or not problem_id then logger.log( 'No platform/contest/problem configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -74,7 +74,10 @@ function M.toggle_interactive(interactor_cmd) and contest_data.index_map and not contest_data.problems[contest_data.index_map[problem_id]].interactive then - logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR) + logger.log( + 'This problem is not interactive. Use :CP {run,panel}.', + { level = vim.log.levels.ERROR } + ) return end @@ -103,7 +106,7 @@ function M.toggle_interactive(interactor_cmd) local binary = state.get_binary_file() if not binary or binary == '' then - logger.log('No binary produced.', vim.log.levels.ERROR) + logger.log('No binary produced.', { level = vim.log.levels.ERROR }) restore_session() return end @@ -117,7 +120,7 @@ function M.toggle_interactive(interactor_cmd) if vim.fn.executable(interactor) ~= 1 then logger.log( ("Interactor '%s' is not executable."):format(interactor_cmd), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) restore_session() return @@ -354,7 +357,7 @@ function M.ensure_io_view() if not platform or not contest_id or not problem_id then logger.log( 'No platform/contest/problem configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -383,7 +386,10 @@ function M.ensure_io_view() and contest_data.index_map and contest_data.problems[contest_data.index_map[problem_id]].interactive then - logger.log('This problem is not interactive. Use :CP {run,panel}.', vim.log.levels.ERROR) + logger.log( + 'This problem is not interactive. Use :CP {run,panel}.', + { level = vim.log.levels.ERROR } + ) return end @@ -594,12 +600,15 @@ end function M.run_io_view(test_indices_arg, debug, mode) if io_view_running then - logger.log('Tests already running', vim.log.levels.WARN) + logger.log('Tests already running', { level = vim.log.levels.WARN }) return end io_view_running = true - logger.log(('%s tests...'):format(debug and 'Debugging' or 'Running'), vim.log.levels.INFO, true) + logger.log( + ('%s tests...'):format(debug and 'Debugging' or 'Running'), + { level = vim.log.levels.INFO, override = true } + ) mode = mode or 'combined' @@ -608,7 +617,7 @@ function M.run_io_view(test_indices_arg, debug, mode) if not platform or not contest_id or not problem_id then logger.log( 'No platform/contest/problem configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) io_view_running = false return @@ -617,7 +626,7 @@ function M.run_io_view(test_indices_arg, debug, mode) cache.load() local contest_data = cache.get_contest_data(platform, contest_id) if not contest_data or not contest_data.index_map then - logger.log('No test cases available.', vim.log.levels.ERROR) + logger.log('No test cases available.', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -634,13 +643,13 @@ function M.run_io_view(test_indices_arg, debug, mode) if mode == 'combined' then local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then - logger.log('No combined test available', vim.log.levels.ERROR) + logger.log('No combined test available', { level = vim.log.levels.ERROR }) io_view_running = false return end else if not run.load_test_cases() then - logger.log('No test cases available', vim.log.levels.ERROR) + logger.log('No test cases available', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -660,7 +669,7 @@ function M.run_io_view(test_indices_arg, debug, mode) idx, #test_state.test_cases ), - vim.log.levels.WARN + { level = vim.log.levels.WARN } ) io_view_running = false return @@ -721,7 +730,7 @@ function M.run_io_view(test_indices_arg, debug, mode) if mode == 'combined' then local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then - logger.log('No combined test found', vim.log.levels.ERROR) + logger.log('No combined test found', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -730,7 +739,7 @@ function M.run_io_view(test_indices_arg, debug, mode) run.run_combined_test(debug, function(result) if not result then - logger.log('Failed to run combined test', vim.log.levels.ERROR) + logger.log('Failed to run combined test', { level = vim.log.levels.ERROR }) io_view_running = false return end @@ -771,7 +780,7 @@ function M.toggle_panel(panel_opts) end if state.get_active_panel() then - logger.log('another panel is already active', vim.log.levels.ERROR) + logger.log('another panel is already active', { level = vim.log.levels.ERROR }) return end @@ -780,7 +789,7 @@ function M.toggle_panel(panel_opts) if not platform or not contest_id then logger.log( 'No platform/contest configured. Use :CP [...] first.', - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return end @@ -792,7 +801,10 @@ function M.toggle_panel(panel_opts) and contest_data.index_map and contest_data.problems[contest_data.index_map[state.get_problem_id()]].interactive then - logger.log('This is an interactive problem. Use :CP interact instead.', vim.log.levels.WARN) + logger.log( + 'This is an interactive problem. Use :CP interact instead.', + { level = vim.log.levels.WARN } + ) return end @@ -803,7 +815,7 @@ function M.toggle_panel(panel_opts) logger.log(('run panel: checking test cases for %s'):format(input_file or 'none')) if not run.load_test_cases() then - logger.log('no test cases found', vim.log.levels.WARN) + logger.log('no test cases found', { level = vim.log.levels.WARN }) return end diff --git a/lua/cp/utils.lua b/lua/cp/utils.lua index 3111fec..48e5dc5 100644 --- a/lua/cp/utils.lua +++ b/lua/cp/utils.lua @@ -152,7 +152,10 @@ local function discover_nix_submit_cmd() :wait() if result.code ~= 0 then - logger.log('nix build #submitEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN) + logger.log( + 'nix build #submitEnv failed: ' .. (result.stderr or ''), + { level = vim.log.levels.WARN } + ) return false end @@ -160,7 +163,7 @@ local function discover_nix_submit_cmd() local submit_cmd = store_path .. '/bin/cp-nvim-submit' if vim.fn.executable(submit_cmd) ~= 1 then - logger.log('nix submit cmd not executable at ' .. submit_cmd, vim.log.levels.WARN) + logger.log('nix submit cmd not executable at ' .. submit_cmd, { level = vim.log.levels.WARN }) return false end @@ -216,7 +219,10 @@ local function discover_nix_python() :wait() if result.code ~= 0 then - logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN) + logger.log( + 'nix build #pythonEnv failed: ' .. (result.stderr or ''), + { level = vim.log.levels.WARN } + ) return false end @@ -224,7 +230,7 @@ local function discover_nix_python() local python_path = store_path .. '/bin/python3' if vim.fn.executable(python_path) ~= 1 then - logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN) + logger.log('nix python not executable at ' .. python_path, { level = vim.log.levels.WARN }) return false end @@ -270,7 +276,7 @@ function M.setup_python_env() if result.code ~= 0 then logger.log( 'Failed to setup Python environment: ' .. (result.stderr or ''), - vim.log.levels.ERROR + { level = vim.log.levels.ERROR } ) return false end @@ -292,7 +298,7 @@ function M.setup_python_env() logger.log( 'No Python environment available. Install uv (https://docs.astral.sh/uv/) or use nix.', - vim.log.levels.WARN + { level = vim.log.levels.WARN } ) return false end diff --git a/plugin/cp.lua b/plugin/cp.lua index db9a8bd..60efb7a 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -43,7 +43,6 @@ end, { vim.list_extend(candidates, platforms) table.insert(candidates, 'cache') table.insert(candidates, 'pick') - if platform and contest_id then vim.list_extend(candidates, actions) local cache = require('cp.cache') @@ -60,10 +59,11 @@ end, { return filter_candidates(candidates) elseif num_args == 3 then if vim.tbl_contains(platforms, args[2]) then + local candidates = { 'login', 'logout' } local cache = require('cp.cache') cache.load() - local contests = cache.get_cached_contest_ids(args[2]) - return filter_candidates(contests) + vim.list_extend(candidates, cache.get_cached_contest_ids(args[2])) + return filter_candidates(candidates) elseif args[2] == 'cache' then return filter_candidates({ 'clear', 'read' }) elseif args[2] == 'stress' or args[2] == 'interact' then @@ -103,8 +103,6 @@ end, { end end return filter_candidates(candidates) - elseif args[2] == 'login' or args[2] == 'logout' then - return filter_candidates(platforms) elseif args[2] == 'race' then local candidates = { 'stop' } vim.list_extend(candidates, platforms) diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 8b3db72..33d3574 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -33,6 +33,7 @@ from .timeouts import ( BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, BROWSER_SETTLE_DELAY, + BROWSER_SUBMIT_NAV_TIMEOUT, BROWSER_TURNSTILE_POLL, HTTP_TIMEOUT, ) @@ -297,7 +298,7 @@ def _ensure_browser() -> None: def _submit_headless( contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], _retried: bool = False, @@ -362,19 +363,12 @@ def _submit_headless( f'select[name="data.LanguageId"] option[value="{language_id}"]' ).wait_for(state="attached", timeout=BROWSER_ELEMENT_WAIT) page.select_option('select[name="data.LanguageId"]', language_id) - ext = _LANGUAGE_ID_EXTENSION.get(language_id, "txt") - page.set_input_files( - "#input-open-file", - { - "name": f"solution.{ext}", - "mimeType": "text/plain", - "buffer": source_code.encode(), - }, - ) + page.set_input_files("#input-open-file", file_path) page.wait_for_timeout(BROWSER_SETTLE_DELAY) page.locator('button[type="submit"]').click() page.wait_for_url( - lambda url: "/submissions/me" in url, timeout=BROWSER_NAV_TIMEOUT + lambda url: "/submissions/me" in url, + timeout=BROWSER_SUBMIT_NAV_TIMEOUT["atcoder"], ) except Exception as e: submit_error = str(e) @@ -423,7 +417,7 @@ def _submit_headless( return _submit_headless( contest_id, problem_id, - source_code, + file_path, language_id, credentials, _retried=True, @@ -581,7 +575,7 @@ class AtcoderScraper(BaseScraper): self, contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], ) -> SubmitResult: @@ -589,7 +583,7 @@ class AtcoderScraper(BaseScraper): _submit_headless, contest_id, problem_id, - source_code, + file_path, language_id, credentials, ) @@ -651,15 +645,14 @@ async def main_async() -> int: return 0 if contest_result.success else 1 if mode == "submit": - if len(sys.argv) != 5: + if len(sys.argv) != 6: print( SubmitResult( success=False, - error="Usage: atcoder.py submit ", + error="Usage: atcoder.py submit ", ).model_dump_json() ) return 1 - source_code = sys.stdin.read() creds_raw = os.environ.get("CP_CREDENTIALS", "{}") try: credentials = json.loads(creds_raw) @@ -667,7 +660,7 @@ async def main_async() -> int: credentials = {} language_id = get_language_id("atcoder", sys.argv[4]) or sys.argv[4] submit_result = await scraper.submit( - sys.argv[2], sys.argv[3], source_code, language_id, credentials + sys.argv[2], sys.argv[3], sys.argv[5], language_id, credentials ) print(submit_result.model_dump_json()) return 0 if submit_result.success else 1 diff --git a/scrapers/base.py b/scrapers/base.py index ed0636b..c77e293 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -53,7 +53,7 @@ class BaseScraper(ABC): self, contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], ) -> SubmitResult: ... @@ -114,14 +114,13 @@ class BaseScraper(ABC): return 0 if result.success else 1 case "submit": - if len(args) != 5: + if len(args) != 6: print( self._submit_error( - "Usage: submit " + "Usage: submit " ).model_dump_json() ) return 1 - source_code = sys.stdin.read() creds_raw = os.environ.get("CP_CREDENTIALS", "{}") try: credentials = json.loads(creds_raw) @@ -129,7 +128,7 @@ class BaseScraper(ABC): credentials = {} language_id = get_language_id(self.platform_name, args[4]) or args[4] result = await self.submit( - args[2], args[3], source_code, language_id, credentials + args[2], args[3], args[5], language_id, credentials ) print(result.model_dump_json()) return 0 if result.success else 1 diff --git a/scrapers/codechef.py b/scrapers/codechef.py index c4b9d37..b7f4ec7 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -33,7 +33,7 @@ MEMORY_LIMIT_RE = re.compile( ) -async def fetch_json(client: httpx.AsyncClient, path: str) -> dict: +async def fetch_json(client: httpx.AsyncClient, path: str) -> dict[str, Any]: r = await client.get(BASE_URL + path, headers=HEADERS, timeout=HTTP_TIMEOUT) r.raise_for_status() return r.json() @@ -256,7 +256,7 @@ class CodeChefScraper(BaseScraper): self, contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], ) -> SubmitResult: diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index d2c3164..5ac615a 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -21,6 +21,7 @@ from .models import ( from .timeouts import ( BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, + BROWSER_SUBMIT_NAV_TIMEOUT, HTTP_TIMEOUT, ) @@ -289,7 +290,7 @@ class CodeforcesScraper(BaseScraper): self, contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], ) -> SubmitResult: @@ -297,7 +298,7 @@ class CodeforcesScraper(BaseScraper): _submit_headless, contest_id, problem_id, - source_code, + file_path, language_id, credentials, ) @@ -306,13 +307,15 @@ class CodeforcesScraper(BaseScraper): def _submit_headless( contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], _retried: bool = False, ) -> SubmitResult: from pathlib import Path + source_code = Path(file_path).read_text() + try: from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] except ImportError: @@ -379,12 +382,22 @@ def _submit_headless( problem_id.upper(), ) page.select_option('select[name="programTypeId"]', language_id) - page.fill('textarea[name="source"]', source_code) + page.evaluate( + """(code) => { + const cm = document.querySelector('.CodeMirror'); + if (cm && cm.CodeMirror) { + cm.CodeMirror.setValue(code); + } + const ta = document.querySelector('textarea[name="source"]'); + if (ta) ta.value = code; + }""", + source_code, + ) page.locator("form.submit-form input.submit").click(no_wait_after=True) try: page.wait_for_url( lambda url: "/my" in url or "/status" in url, - timeout=BROWSER_NAV_TIMEOUT * 2, + timeout=BROWSER_SUBMIT_NAV_TIMEOUT["codeforces"], ) except Exception: err_el = page.query_selector("span.error") @@ -441,7 +454,7 @@ def _submit_headless( return _submit_headless( contest_id, problem_id, - source_code, + file_path, language_id, credentials, _retried=True, diff --git a/scrapers/cses.py b/scrapers/cses.py index 7d9f4f0..4df3fcc 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -357,10 +357,13 @@ class CSESScraper(BaseScraper): self, contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], ) -> SubmitResult: + from pathlib import Path + + source_code = Path(file_path).read_text() username = credentials.get("username", "") password = credentials.get("password", "") if not username or not password: diff --git a/scrapers/kattis.py b/scrapers/kattis.py index 2bfd2d6..566473c 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -273,7 +273,7 @@ class KattisScraper(BaseScraper): self, contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], ) -> SubmitResult: diff --git a/scrapers/timeouts.py b/scrapers/timeouts.py index a21ad0d..1862349 100644 --- a/scrapers/timeouts.py +++ b/scrapers/timeouts.py @@ -1,7 +1,13 @@ +from collections import defaultdict + HTTP_TIMEOUT = 15.0 BROWSER_SESSION_TIMEOUT = 15000 BROWSER_NAV_TIMEOUT = 10000 +BROWSER_SUBMIT_NAV_TIMEOUT: defaultdict[str, int] = defaultdict( + lambda: BROWSER_NAV_TIMEOUT +) +BROWSER_SUBMIT_NAV_TIMEOUT["codeforces"] = BROWSER_NAV_TIMEOUT * 2 BROWSER_TURNSTILE_POLL = 5000 BROWSER_ELEMENT_WAIT = 10000 BROWSER_SETTLE_DELAY = 500 diff --git a/scrapers/usaco.py b/scrapers/usaco.py index 9e4d7da..099f102 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -73,8 +73,11 @@ def _parse_results_page(html: str) -> dict[str, list[tuple[str, str]]]: for part in parts: heading_m = DIVISION_HEADING_RE.search(part) if heading_m: - current_div = heading_m.group(3).lower() - sections.setdefault(current_div, []) + div = heading_m.group(3) + if div: + key = div.lower() + current_div = key + sections.setdefault(key, []) continue if current_div is not None: for m in PROBLEM_BLOCK_RE.finditer(part): @@ -285,7 +288,7 @@ class USACOScraper(BaseScraper): self, contest_id: str, problem_id: str, - source_code: str, + file_path: str, language_id: str, credentials: dict[str, str], ) -> SubmitResult: From 2c119774df1c67dc2a2b19adb5f2a8f435236f39 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:12:09 -0500 Subject: [PATCH 081/114] feat: validate credentials on :CP login (#310) ## Problem `:CP login` blindly caches username/password without server-side validation. Bad credentials are only discovered at submit time, which is confusing and wastes a browser session. ## Solution Wire `:CP login` through the scraper pipeline so each platform actually authenticates before persisting credentials. On failure, the user sees an error and nothing is cached. - CSES: reuses `_check_token` (fast path) and `_web_login`; returns API token in `LoginResult.credentials` so subsequent submits skip re-auth. - AtCoder/Codeforces: new `_login_headless` functions open a StealthySession, solve Turnstile/Cloudflare, fill the login form, and validate success by checking for the logout link. Cookies only persist on confirmed login. - CodeChef/Kattis/USACO: return "not yet implemented" errors. - `scraper.lua`: generalizes submit-only guards (`needs_browser` flag) to cover both `submit` and `login` subcommands. - `credentials.lua`: prompts for username/password, passes cached token for CSES fast path, shows ndjson status notifications, only caches on success. --- lua/cp/credentials.lua | 51 +++++++++-- lua/cp/scraper.lua | 43 ++++++++-- scrapers/atcoder.py | 189 ++++++++++++++++++++--------------------- scrapers/base.py | 19 ++++- scrapers/codechef.py | 4 + scrapers/codeforces.py | 106 +++++++++++++++++++++++ scrapers/cses.py | 38 +++++++++ scrapers/kattis.py | 4 + scrapers/models.py | 6 ++ scrapers/usaco.py | 4 + 10 files changed, 356 insertions(+), 108 deletions(-) diff --git a/lua/cp/credentials.lua b/lua/cp/credentials.lua index 76945b6..031b464 100644 --- a/lua/cp/credentials.lua +++ b/lua/cp/credentials.lua @@ -1,34 +1,70 @@ local M = {} local cache = require('cp.cache') +local constants = require('cp.constants') local logger = require('cp.log') local state = require('cp.state') +local STATUS_MESSAGES = { + checking_login = 'Checking existing session...', + logging_in = 'Logging in...', + installing_browser = 'Installing browser...', +} + function M.login(platform) platform = platform or state.get_platform() if not platform then logger.log( - 'No platform specified. Usage: :CP login ', + 'No platform specified. Usage: :CP login', { level = vim.log.levels.ERROR } ) return end - vim.ui.input({ prompt = platform .. ' username: ' }, function(username) + local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform + + vim.ui.input({ prompt = display .. ' username: ' }, function(username) if not username or username == '' then logger.log('Cancelled', { level = vim.log.levels.WARN }) return end vim.fn.inputsave() - local password = vim.fn.inputsecret(platform .. ' password: ') + local password = vim.fn.inputsecret(display .. ' password: ') vim.fn.inputrestore() if not password or password == '' then logger.log('Cancelled', { level = vim.log.levels.WARN }) return end + cache.load() - cache.set_credentials(platform, { username = username, password = password }) - logger.log(platform .. ' credentials saved', { level = vim.log.levels.INFO, override = true }) + local existing = cache.get_credentials(platform) or {} + local credentials = { + username = username, + password = password, + } + if existing.token then + credentials.token = existing.token + end + + local scraper = require('cp.scraper') + scraper.login(platform, credentials, function(ev) + vim.schedule(function() + local msg = STATUS_MESSAGES[ev.status] or ev.status + logger.log(display .. ': ' .. msg, { level = vim.log.levels.INFO, override = true }) + end) + end, function(result) + vim.schedule(function() + if result.success then + logger.log( + display .. ' login successful', + { level = vim.log.levels.INFO, override = true } + ) + else + local err = result.error or 'unknown error' + logger.log(display .. ' login failed: ' .. err, { level = vim.log.levels.ERROR }) + end + end) + end) end) end @@ -36,14 +72,15 @@ function M.logout(platform) platform = platform or state.get_platform() if not platform then logger.log( - 'No platform specified. Usage: :CP logout ', + 'No platform specified. Usage: :CP logout', { level = vim.log.levels.ERROR } ) return end + local display = constants.PLATFORM_DISPLAY_NAMES[platform] or platform cache.load() cache.clear_credentials(platform) - logger.log(platform .. ' credentials cleared', { level = vim.log.levels.INFO, override = true }) + logger.log(display .. ' credentials cleared', { level = vim.log.levels.INFO, override = true }) end return M diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index af170d3..8f9759d 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -44,13 +44,15 @@ local function run_scraper(platform, subcommand, args, opts) return { success = false, error = msg } end - if subcommand == 'submit' then + local needs_browser = subcommand == 'submit' or subcommand == 'login' + + if needs_browser then utils.setup_nix_submit_env() end local plugin_path = utils.get_plugin_path() local cmd - if subcommand == 'submit' then + if needs_browser then cmd = utils.get_python_submit_cmd(platform, plugin_path) else cmd = utils.get_python_cmd(platform, plugin_path) @@ -71,7 +73,7 @@ local function run_scraper(platform, subcommand, args, opts) end end - if subcommand == 'submit' and utils.is_nix_build() then + if needs_browser and utils.is_nix_build() then env.UV_PROJECT_ENVIRONMENT = vim.fn.stdpath('cache') .. '/cp-nvim/submit-env' end @@ -129,7 +131,7 @@ local function run_scraper(platform, subcommand, args, opts) return { success = false, error = 'spawn failed' } end - if subcommand == 'submit' then + if needs_browser then timer = uv.new_timer() timer:start(120000, 0, function() timer:stop() @@ -193,7 +195,7 @@ local function run_scraper(platform, subcommand, args, opts) local sysopts = { text = true, - timeout = (subcommand == 'submit') and 120000 or 30000, + timeout = needs_browser and 120000 or 30000, env = env, cwd = plugin_path, } @@ -317,6 +319,37 @@ function M.scrape_all_tests(platform, contest_id, callback, on_done) }) end +function M.login(platform, credentials, on_status, callback) + local done = false + run_scraper(platform, 'login', {}, { + ndjson = true, + env_extra = { CP_CREDENTIALS = vim.json.encode(credentials) }, + on_event = function(ev) + if ev.credentials ~= nil and next(ev.credentials) ~= nil then + require('cp.cache').set_credentials(platform, ev.credentials) + end + if ev.status ~= nil then + if type(on_status) == 'function' then + on_status(ev) + end + elseif ev.success ~= nil then + done = true + if type(callback) == 'function' then + callback(ev) + end + end + end, + on_exit = function(proc) + if not done and type(callback) == 'function' then + callback({ + success = false, + error = 'login process exited (code=' .. tostring(proc.code) .. ')', + }) + end + end, + }) +end + function M.submit( platform, contest_id, diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 33d3574..966940a 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -5,8 +5,8 @@ import json import os import re import subprocess -import sys import time +from pathlib import Path from typing import Any import backoff @@ -17,16 +17,14 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from .base import BaseScraper, extract_precision -from .language_ids import get_language_id from .models import ( - CombinedTest, ContestListResult, ContestSummary, + LoginResult, MetadataResult, ProblemSummary, SubmitResult, TestCase, - TestsResult, ) from .timeouts import ( BROWSER_ELEMENT_WAIT, @@ -295,6 +293,93 @@ def _ensure_browser() -> None: break +def _login_headless(credentials: dict[str, str]) -> LoginResult: + try: + from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] + except ImportError: + return LoginResult( + success=False, + error="scrapling is required for AtCoder login. Install it: uv add 'scrapling[fetchers]>=0.4'", + ) + + _ensure_browser() + + cookie_cache = Path.home() / ".cache" / "cp-nvim" / "atcoder-cookies.json" + cookie_cache.parent.mkdir(parents=True, exist_ok=True) + saved_cookies: list[dict[str, Any]] = [] + if cookie_cache.exists(): + try: + saved_cookies = json.loads(cookie_cache.read_text()) + except Exception: + pass + + logged_in = False + login_error: str | None = None + + def check_login(page): + nonlocal logged_in + logged_in = page.evaluate( + "() => Array.from(document.querySelectorAll('a')).some(a => a.textContent.trim() === 'Sign Out')" + ) + + def login_action(page): + nonlocal login_error + try: + _solve_turnstile(page) + page.fill('input[name="username"]', credentials.get("username", "")) + page.fill('input[name="password"]', credentials.get("password", "")) + page.click("#submit") + page.wait_for_url( + lambda url: "/login" not in url, timeout=BROWSER_NAV_TIMEOUT + ) + except Exception as e: + login_error = str(e) + + try: + with StealthySession( + headless=True, + timeout=BROWSER_SESSION_TIMEOUT, + google_search=False, + cookies=saved_cookies if saved_cookies else [], + ) as session: + if saved_cookies: + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/home", page_action=check_login, network_idle=True + ) + + if not logged_in: + print(json.dumps({"status": "logging_in"}), flush=True) + session.fetch( + f"{BASE_URL}/login", + page_action=login_action, + solve_cloudflare=True, + ) + if login_error: + return LoginResult( + success=False, error=f"Login failed: {login_error}" + ) + + session.fetch( + f"{BASE_URL}/home", page_action=check_login, network_idle=True + ) + if not logged_in: + return LoginResult( + success=False, error="Login failed (bad credentials?)" + ) + + try: + browser_cookies = session.context.cookies() + if any(c["name"] == "REVEL_SESSION" for c in browser_cookies): + cookie_cache.write_text(json.dumps(browser_cookies)) + except Exception: + pass + + return LoginResult(success=True, error="") + except Exception as e: + return LoginResult(success=False, error=str(e)) + + def _submit_headless( contest_id: str, problem_id: str, @@ -303,8 +388,6 @@ def _submit_headless( credentials: dict[str, str], _retried: bool = False, ) -> "SubmitResult": - from pathlib import Path - try: from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] except ImportError: @@ -588,95 +671,11 @@ class AtcoderScraper(BaseScraper): credentials, ) - -async def main_async() -> int: - if len(sys.argv) < 2: - result = MetadataResult( - success=False, - error="Usage: atcoder.py metadata OR atcoder.py tests OR atcoder.py contests", - url="", - ) - print(result.model_dump_json()) - return 1 - - mode: str = sys.argv[1] - scraper = AtcoderScraper() - - if mode == "metadata": - if len(sys.argv) != 3: - result = MetadataResult( - success=False, - error="Usage: atcoder.py metadata ", - url="", - ) - print(result.model_dump_json()) - return 1 - contest_id = sys.argv[2] - result = await scraper.scrape_contest_metadata(contest_id) - print(result.model_dump_json()) - return 0 if result.success else 1 - - if mode == "tests": - if len(sys.argv) != 3: - tests_result = TestsResult( - success=False, - error="Usage: atcoder.py tests ", - problem_id="", - combined=CombinedTest(input="", expected=""), - tests=[], - timeout_ms=0, - memory_mb=0, - ) - print(tests_result.model_dump_json()) - return 1 - contest_id = sys.argv[2] - await scraper.stream_tests_for_category_async(contest_id) - return 0 - - if mode == "contests": - if len(sys.argv) != 2: - contest_result = ContestListResult( - success=False, error="Usage: atcoder.py contests" - ) - print(contest_result.model_dump_json()) - return 1 - contest_result = await scraper.scrape_contest_list() - print(contest_result.model_dump_json()) - return 0 if contest_result.success else 1 - - if mode == "submit": - if len(sys.argv) != 6: - print( - SubmitResult( - success=False, - error="Usage: atcoder.py submit ", - ).model_dump_json() - ) - return 1 - creds_raw = os.environ.get("CP_CREDENTIALS", "{}") - try: - credentials = json.loads(creds_raw) - except json.JSONDecodeError: - credentials = {} - language_id = get_language_id("atcoder", sys.argv[4]) or sys.argv[4] - submit_result = await scraper.submit( - sys.argv[2], sys.argv[3], sys.argv[5], language_id, credentials - ) - print(submit_result.model_dump_json()) - return 0 if submit_result.success else 1 - - result = MetadataResult( - success=False, - error="Unknown mode. Use 'metadata ', 'tests ', 'contests', or 'submit '", - url="", - ) - print(result.model_dump_json()) - return 1 - - -def main() -> None: - sys.exit(asyncio.run(main_async())) + async def login(self, credentials: dict[str, str]) -> LoginResult: + if not credentials.get("username") or not credentials.get("password"): + return self._login_error("Missing username or password") + return await asyncio.to_thread(_login_headless, credentials) if __name__ == "__main__": - main() + AtcoderScraper().run_cli() diff --git a/scrapers/base.py b/scrapers/base.py index c77e293..11ab8c6 100644 --- a/scrapers/base.py +++ b/scrapers/base.py @@ -9,6 +9,7 @@ from .language_ids import get_language_id from .models import ( CombinedTest, ContestListResult, + LoginResult, MetadataResult, SubmitResult, TestsResult, @@ -58,9 +59,12 @@ class BaseScraper(ABC): credentials: dict[str, str], ) -> SubmitResult: ... + @abstractmethod + async def login(self, credentials: dict[str, str]) -> LoginResult: ... + def _usage(self) -> str: name = self.platform_name - return f"Usage: {name}.py metadata | tests | contests" + return f"Usage: {name}.py metadata | tests | contests | login" def _metadata_error(self, msg: str) -> MetadataResult: return MetadataResult(success=False, error=msg, url="") @@ -82,6 +86,9 @@ class BaseScraper(ABC): def _submit_error(self, msg: str) -> SubmitResult: return SubmitResult(success=False, error=msg) + def _login_error(self, msg: str) -> LoginResult: + return LoginResult(success=False, error=msg) + async def _run_cli_async(self, args: list[str]) -> int: if len(args) < 2: print(self._metadata_error(self._usage()).model_dump_json()) @@ -133,6 +140,16 @@ class BaseScraper(ABC): print(result.model_dump_json()) return 0 if result.success else 1 + case "login": + creds_raw = os.environ.get("CP_CREDENTIALS", "{}") + try: + credentials = json.loads(creds_raw) + except json.JSONDecodeError: + credentials = {} + result = await self.login(credentials) + print(result.model_dump_json()) + return 0 if result.success else 1 + case _: print( self._metadata_error( diff --git a/scrapers/codechef.py b/scrapers/codechef.py index b7f4ec7..200d5e2 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -13,6 +13,7 @@ from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, + LoginResult, MetadataResult, ProblemSummary, SubmitResult, @@ -267,6 +268,9 @@ class CodeChefScraper(BaseScraper): verdict="", ) + async def login(self, credentials: dict[str, str]) -> LoginResult: + return self._login_error("CodeChef login not yet implemented") + if __name__ == "__main__": CodeChefScraper().run_cli() diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 5ac615a..a0a0fe2 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -13,6 +13,7 @@ from .base import BaseScraper, extract_precision from .models import ( ContestListResult, ContestSummary, + LoginResult, MetadataResult, ProblemSummary, SubmitResult, @@ -303,6 +304,111 @@ class CodeforcesScraper(BaseScraper): credentials, ) + async def login(self, credentials: dict[str, str]) -> LoginResult: + if not credentials.get("username") or not credentials.get("password"): + return self._login_error("Missing username or password") + return await asyncio.to_thread(_login_headless_cf, credentials) + + +def _login_headless_cf(credentials: dict[str, str]) -> LoginResult: + from pathlib import Path + + try: + from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] + except ImportError: + return LoginResult( + success=False, + error="scrapling is required for Codeforces login", + ) + + from .atcoder import _ensure_browser + + _ensure_browser() + + cookie_cache = Path.home() / ".cache" / "cp-nvim" / "codeforces-cookies.json" + cookie_cache.parent.mkdir(parents=True, exist_ok=True) + saved_cookies: list[dict[str, Any]] = [] + if cookie_cache.exists(): + try: + saved_cookies = json.loads(cookie_cache.read_text()) + except Exception: + pass + + logged_in = False + login_error: str | None = None + + def check_login(page): + nonlocal logged_in + logged_in = page.evaluate( + "() => Array.from(document.querySelectorAll('a'))" + ".some(a => a.textContent.includes('Logout'))" + ) + + def login_action(page): + nonlocal login_error + try: + page.fill( + 'input[name="handleOrEmail"]', + credentials.get("username", ""), + ) + page.fill( + 'input[name="password"]', + credentials.get("password", ""), + ) + page.locator('#enterForm input[type="submit"]').click() + page.wait_for_url( + lambda url: "/enter" not in url, timeout=BROWSER_NAV_TIMEOUT + ) + except Exception as e: + login_error = str(e) + + try: + with StealthySession( + headless=True, + timeout=BROWSER_SESSION_TIMEOUT, + google_search=False, + cookies=saved_cookies if saved_cookies else [], + ) as session: + if saved_cookies: + print(json.dumps({"status": "checking_login"}), flush=True) + session.fetch( + f"{BASE_URL}/", + page_action=check_login, + network_idle=True, + ) + + if not logged_in: + print(json.dumps({"status": "logging_in"}), flush=True) + session.fetch( + f"{BASE_URL}/enter", + page_action=login_action, + solve_cloudflare=True, + ) + if login_error: + return LoginResult( + success=False, error=f"Login failed: {login_error}" + ) + + session.fetch( + f"{BASE_URL}/", + page_action=check_login, + network_idle=True, + ) + if not logged_in: + return LoginResult( + success=False, error="Login failed (bad credentials?)" + ) + + try: + browser_cookies = session.context.cookies() + cookie_cache.write_text(json.dumps(browser_cookies)) + except Exception: + pass + + return LoginResult(success=True, error="") + except Exception as e: + return LoginResult(success=False, error=str(e)) + def _submit_headless( contest_id: str, diff --git a/scrapers/cses.py b/scrapers/cses.py index 4df3fcc..ef5deda 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -13,6 +13,7 @@ from .timeouts import HTTP_TIMEOUT, SUBMIT_POLL_TIMEOUT from .models import ( ContestListResult, ContestSummary, + LoginResult, MetadataResult, ProblemSummary, SubmitResult, @@ -229,6 +230,43 @@ class CSESScraper(BaseScraper): ) return ContestListResult(success=True, error="", contests=cats) + async def login(self, credentials: dict[str, str]) -> LoginResult: + username = credentials.get("username", "") + password = credentials.get("password", "") + if not username or not password: + return self._login_error("Missing username or password") + + async with httpx.AsyncClient(follow_redirects=True) as client: + token = credentials.get("token") + + if token: + print(json.dumps({"status": "checking_login"}), flush=True) + if await self._check_token(client, token): + return LoginResult( + success=True, + error="", + credentials={ + "username": username, + "password": password, + "token": token, + }, + ) + + print(json.dumps({"status": "logging_in"}), flush=True) + token = await self._web_login(client, username, password) + if not token: + return self._login_error("Login failed (bad credentials?)") + + return LoginResult( + success=True, + error="", + credentials={ + "username": username, + "password": password, + "token": token, + }, + ) + async def stream_tests_for_category_async(self, category_id: str) -> None: async with httpx.AsyncClient( limits=httpx.Limits(max_connections=CONNECTIONS) diff --git a/scrapers/kattis.py b/scrapers/kattis.py index 566473c..9b11395 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -14,6 +14,7 @@ from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, + LoginResult, MetadataResult, ProblemSummary, SubmitResult, @@ -284,6 +285,9 @@ class KattisScraper(BaseScraper): verdict="", ) + async def login(self, credentials: dict[str, str]) -> LoginResult: + return self._login_error("Kattis login not yet implemented") + if __name__ == "__main__": KattisScraper().run_cli() diff --git a/scrapers/models.py b/scrapers/models.py index 68de9a9..4dafc64 100644 --- a/scrapers/models.py +++ b/scrapers/models.py @@ -64,6 +64,12 @@ class TestsResult(ScrapingResult): model_config = ConfigDict(extra="forbid") +class LoginResult(ScrapingResult): + credentials: dict[str, str] = Field(default_factory=dict) + + model_config = ConfigDict(extra="forbid") + + class SubmitResult(ScrapingResult): submission_id: str = "" verdict: str = "" diff --git a/scrapers/usaco.py b/scrapers/usaco.py index 099f102..221811c 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -12,6 +12,7 @@ from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, ContestSummary, + LoginResult, MetadataResult, ProblemSummary, SubmitResult, @@ -299,6 +300,9 @@ class USACOScraper(BaseScraper): verdict="", ) + async def login(self, credentials: dict[str, str]) -> LoginResult: + return self._login_error("USACO login not yet implemented") + if __name__ == "__main__": USACOScraper().run_cli() From 924601ce993437390d4c57663ec21f9b6c6e119f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:31:29 -0500 Subject: [PATCH 082/114] fix(codeforces): persist cookies after submit regardless of name (#312) ## Problem The submit path in `codeforces.py` guarded `cookie_cache.write_text` on the presence of a `JSESSIONID` cookie. Codeforces does not use `JSESSIONID`, so the cookie file was never written after submit, breaking the fast-path on subsequent submits. ## Solution Replace the name-specific guard with a non-empty check (`if browser_cookies:`), matching the unconditional save already used in the login path. Closes #301. --- scrapers/codeforces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index a0a0fe2..7a96483 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -550,7 +550,7 @@ def _submit_headless( try: browser_cookies = session.context.cookies() - if any(c["name"] == "JSESSIONID" for c in browser_cookies): + if browser_cookies: cookie_cache.write_text(json.dumps(browser_cookies)) except Exception: pass From b3014e9c86c4ffc5f459ff5e0dd19e80b08d2277 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:31:47 -0500 Subject: [PATCH 083/114] fix(setup): clear output buffer when switching contests (#313) ## Problem `setup_problem` only cleared the output buffer when `old_problem_id ~= problem_id`. If two different contests share a problem with the same ID (e.g. both have `a`), the condition is false and stale output from the previous contest remains visible. ## Solution Clear the output buffer at the top of `proceed()` in `setup_contest` whenever `is_new_contest` is true, before any problem setup runs. Closes #303. --- lua/cp/setup.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index eb7ba8c..7a33cd0 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -182,6 +182,12 @@ function M.setup_contest(platform, contest_id, problem_id, language) cache.load() local function proceed(contest_data) + if is_new_contest then + local io_state = state.get_io_view_state() + if io_state and io_state.output_buf and vim.api.nvim_buf_is_valid(io_state.output_buf) then + require('cp.utils').update_buffer_content(io_state.output_buf, {}, nil, nil) + end + end local problems = contest_data.problems local pid = problem_id and problem_id or problems[1].id M.setup_problem(pid, language) From 916c9174a61ee413f2c1cf5ef9b6cba19781f56a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:37:33 -0500 Subject: [PATCH 084/114] fix(commands): canonicalize CF contest IDs and auto-restore on action (#314) ## Problem Two gaps in `commands/init.lua`. Codeforces contest IDs were passed through raw, so full URLs (e.g. `https://codeforces.com/contest/1933/problem/A`) or problem IDs with trailing letters (e.g. `1933A`) caused scraper URL construction to fail. Separately, action commands like `:CP run` silently failed when invoked with no active contest instead of attempting to recover from the current file's cached state. ## Solution Add `canonicalize_cf_contest` to normalize URLs and strip trailing problem letters in `parse_command`. Add a guard in `handle_command` that calls `restore_from_current_file()` before dispatching any contest-requiring action when no platform is active, returning early only if no cached state is found. Closes #306. Closes #308. --- lua/cp/commands/init.lua | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 4c594bd..de6d307 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -25,6 +25,20 @@ local actions = constants.ACTIONS ---@field language? string ---@field subcommand? string +---@param str string +---@return string +local function canonicalize_cf_contest(str) + local id = str:match('/contest/(%d+)') or str:match('/problemset/problem/(%d+)') + if id then + return id + end + local num = str:match('^(%d+)[A-Za-z]') + if num then + return num + end + return str +end + --- Turn raw args into normalized structure to later dispatch ---@param args string[] The raw command-line mode args ---@return ParsedCommand @@ -246,16 +260,24 @@ local function parse_command(args) if args[2] == 'login' or args[2] == 'logout' then return { type = 'action', action = args[2], platform = first } end + local contest = args[2] + if first == 'codeforces' then + contest = canonicalize_cf_contest(contest) + end return { type = 'contest_setup', platform = first, - contest = args[2], + contest = contest, } elseif #args == 4 and args[3] == '--lang' then + local contest = args[2] + if first == 'codeforces' then + contest = canonicalize_cf_contest(contest) + end return { type = 'contest_setup', platform = first, - contest = args[2], + contest = contest, language = args[4], } else @@ -296,6 +318,15 @@ function M.handle_command(opts) local restore = require('cp.restore') restore.restore_from_current_file() elseif cmd.type == 'action' then + local CONTEST_ACTIONS = + { 'run', 'panel', 'edit', 'interact', 'stress', 'submit', 'next', 'prev', 'pick' } + if vim.tbl_contains(CONTEST_ACTIONS, cmd.action) and not state.get_platform() then + local restore = require('cp.restore') + if not restore.restore_from_current_file() then + return + end + end + local setup = require('cp.setup') local ui = require('cp.ui.views') From 7e7e135681ddca3a69bc39c98c071d9683e4cd7e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:42:50 -0500 Subject: [PATCH 085/114] fix: cancel active process on contest switch (#316) ## Problem Switching contests while a run, interactive session, or stress test was active left orphaned callbacks and terminal jobs alive. Running `:CP run` twice also let the first run's stale output overwrite the buffer. ## Solution Replace the `io_view_running` bool in `views.lua` with a generation counter so concurrent `run_io_view` calls self-cancel via stale-gen checks in async callbacks. Add `cancel_io_view`, `cancel_interactive`, and `stress.cancel` for forceful teardown, and call them in `setup_contest` whenever `is_new_contest` is true. --- lua/cp/setup.lua | 13 ++++++++++++ lua/cp/stress.lua | 14 +++++++++++++ lua/cp/ui/views.lua | 48 ++++++++++++++++++++++++++++----------------- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 7a33cd0..1609e61 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -179,6 +179,19 @@ function M.setup_contest(platform, contest_id, problem_id, language) local is_new_contest = old_platform ~= platform or old_contest_id ~= contest_id + if is_new_contest then + local views = require('cp.ui.views') + views.cancel_io_view() + local active = state.get_active_panel() + if active == 'interactive' then + views.cancel_interactive() + elseif active == 'stress' then + require('cp.stress').cancel() + elseif active == 'run' then + views.disable() + end + end + cache.load() local function proceed(contest_data) diff --git a/lua/cp/stress.lua b/lua/cp/stress.lua index 9063d0d..ec3cba5 100644 --- a/lua/cp/stress.lua +++ b/lua/cp/stress.lua @@ -232,4 +232,18 @@ function M.toggle(generator_cmd, brute_cmd) end) end +function M.cancel() + if state.stress_buf and vim.api.nvim_buf_is_valid(state.stress_buf) then + local job = vim.b[state.stress_buf].terminal_job_id + if job then + vim.fn.jobstop(job) + end + end + if state.saved_stress_session then + vim.fn.delete(state.saved_stress_session) + state.saved_stress_session = nil + end + state.set_active_panel(nil) +end + return M diff --git a/lua/cp/ui/views.lua b/lua/cp/ui/views.lua index b2ee23d..599bded 100644 --- a/lua/cp/ui/views.lua +++ b/lua/cp/ui/views.lua @@ -14,7 +14,7 @@ local utils = require('cp.utils') local current_diff_layout = nil local current_mode = nil -local io_view_running = false +local _run_gen = 0 function M.disable() local active_panel = state.get_active_panel() @@ -599,11 +599,8 @@ local function render_io_view_results(io_state, test_indices, mode, combined_res end function M.run_io_view(test_indices_arg, debug, mode) - if io_view_running then - logger.log('Tests already running', { level = vim.log.levels.WARN }) - return - end - io_view_running = true + _run_gen = _run_gen + 1 + local gen = _run_gen logger.log( ('%s tests...'):format(debug and 'Debugging' or 'Running'), @@ -619,7 +616,6 @@ function M.run_io_view(test_indices_arg, debug, mode) 'No platform/contest/problem configured. Use :CP [...] first.', { level = vim.log.levels.ERROR } ) - io_view_running = false return end @@ -627,7 +623,6 @@ function M.run_io_view(test_indices_arg, debug, mode) local contest_data = cache.get_contest_data(platform, contest_id) if not contest_data or not contest_data.index_map then logger.log('No test cases available.', { level = vim.log.levels.ERROR }) - io_view_running = false return end @@ -644,13 +639,11 @@ function M.run_io_view(test_indices_arg, debug, mode) local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then logger.log('No combined test available', { level = vim.log.levels.ERROR }) - io_view_running = false return end else if not run.load_test_cases() then logger.log('No test cases available', { level = vim.log.levels.ERROR }) - io_view_running = false return end end @@ -671,7 +664,6 @@ function M.run_io_view(test_indices_arg, debug, mode) ), { level = vim.log.levels.WARN } ) - io_view_running = false return end end @@ -689,7 +681,6 @@ function M.run_io_view(test_indices_arg, debug, mode) local io_state = state.get_io_view_state() if not io_state then - io_view_running = false return end @@ -702,8 +693,10 @@ function M.run_io_view(test_indices_arg, debug, mode) local execute = require('cp.runner.execute') execute.compile_problem(debug, function(compile_result) + if gen ~= _run_gen then + return + end if not vim.api.nvim_buf_is_valid(io_state.output_buf) then - io_view_running = false return end @@ -723,7 +716,6 @@ function M.run_io_view(test_indices_arg, debug, mode) local ns = vim.api.nvim_create_namespace('cp_io_view_compile_error') utils.update_buffer_content(io_state.output_buf, lines, highlights, ns) - io_view_running = false return end @@ -731,35 +723,55 @@ function M.run_io_view(test_indices_arg, debug, mode) local combined = cache.get_combined_test(platform, contest_id, problem_id) if not combined then logger.log('No combined test found', { level = vim.log.levels.ERROR }) - io_view_running = false return end run.load_test_cases() run.run_combined_test(debug, function(result) + if gen ~= _run_gen then + return + end if not result then logger.log('Failed to run combined test', { level = vim.log.levels.ERROR }) - io_view_running = false return end if vim.api.nvim_buf_is_valid(io_state.output_buf) then render_io_view_results(io_state, test_indices, mode, result, combined.input) end - io_view_running = false end) else run.run_all_test_cases(test_indices, debug, nil, function() + if gen ~= _run_gen then + return + end if vim.api.nvim_buf_is_valid(io_state.output_buf) then render_io_view_results(io_state, test_indices, mode, nil, nil) end - io_view_running = false end) end end) end +function M.cancel_io_view() + _run_gen = _run_gen + 1 +end + +function M.cancel_interactive() + if state.interactive_buf and vim.api.nvim_buf_is_valid(state.interactive_buf) then + local job = vim.b[state.interactive_buf].terminal_job_id + if job then + vim.fn.jobstop(job) + end + end + if state.saved_interactive_session then + vim.fn.delete(state.saved_interactive_session) + state.saved_interactive_session = nil + end + state.set_active_panel(nil) +end + ---@param panel_opts? PanelOpts function M.toggle_panel(panel_opts) if state.get_active_panel() == 'run' then From 6c036a7b2e03db01c2e49ba6b919526019b86acb Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:29:41 -0500 Subject: [PATCH 086/114] fix: systematic context guards and full panel lifecycle (#317) ## Problem The `CONTEST_ACTIONS` list in `handle_command` was a manually maintained parallel of `ACTIONS` that had already drifted (`pick` was incorrectly included, breaking `:CP pick` cold). `navigate_problem` only cancelled the `'run'` panel on `next`/`prev`, leaving interactive and stress terminals alive and in-flight `run_io_view` callbacks unguarded. `pick` and `cache` also appeared twice in tab-completion when a contest was active. ## Solution Replace `CONTEST_ACTIONS` with a `requires_context` field on `ParsedCommand`, set at each parse-site in `parse_command` so the semantics live with the action definition. Expand `navigate_problem` to call `cancel_io_view()` and dispatch `cancel_interactive`/`stress.cancel` for all panel types, mirroring the contest-switch logic. Filter already-present `pick` and `cache` from the context-conditional completion candidates. --- lua/cp/commands/init.lua | 36 +++++++++++++++++++++++++++--------- lua/cp/setup.lua | 8 +++++++- plugin/cp.lua | 7 ++++++- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index de6d307..5006cc9 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -11,6 +11,7 @@ local actions = constants.ACTIONS ---@field type string ---@field error string? ---@field action? string +---@field requires_context? boolean ---@field message? string ---@field contest? string ---@field platform? string @@ -71,7 +72,7 @@ local function parse_command(args) end elseif first == 'race' then if args[2] == 'stop' then - return { type = 'action', action = 'race_stop' } + return { type = 'action', action = 'race_stop', requires_context = false } end if not args[2] or not args[3] then return { @@ -86,6 +87,7 @@ local function parse_command(args) return { type = 'action', action = 'race', + requires_context = false, platform = args[2], contest = args[3], language = language, @@ -93,14 +95,20 @@ local function parse_command(args) elseif first == 'interact' then local inter = args[2] if inter and inter ~= '' then - return { type = 'action', action = 'interact', interactor_cmd = inter } + return { + type = 'action', + action = 'interact', + requires_context = true, + interactor_cmd = inter, + } else - return { type = 'action', action = 'interact' } + return { type = 'action', action = 'interact', requires_context = true } end elseif first == 'stress' then return { type = 'action', action = 'stress', + requires_context = true, generator_cmd = args[2], brute_cmd = args[3], } @@ -119,7 +127,7 @@ local function parse_command(args) end test_index = idx end - return { type = 'action', action = 'edit', test_index = test_index } + return { type = 'action', action = 'edit', requires_context = true, test_index = test_index } elseif first == 'run' or first == 'panel' then local debug = false local test_indices = nil @@ -232,10 +240,22 @@ local function parse_command(args) return { type = 'action', action = first, + requires_context = true, test_indices = test_indices, debug = debug, mode = mode, } + elseif first == 'pick' then + local language = nil + if #args >= 3 and args[2] == '--lang' then + language = args[3] + elseif #args >= 2 and args[2] ~= nil and args[2]:sub(1, 2) ~= '--' then + return { + type = 'error', + message = ("Unknown argument '%s' for action '%s'"):format(args[2], first), + } + end + return { type = 'action', action = 'pick', requires_context = false, language = language } else local language = nil if #args >= 3 and args[2] == '--lang' then @@ -246,7 +266,7 @@ local function parse_command(args) message = ("Unknown argument '%s' for action '%s'"):format(args[2], first), } end - return { type = 'action', action = first, language = language } + return { type = 'action', action = first, requires_context = true, language = language } end end @@ -258,7 +278,7 @@ local function parse_command(args) } elseif #args == 2 then if args[2] == 'login' or args[2] == 'logout' then - return { type = 'action', action = args[2], platform = first } + return { type = 'action', action = args[2], requires_context = false, platform = first } end local contest = args[2] if first == 'codeforces' then @@ -318,9 +338,7 @@ function M.handle_command(opts) local restore = require('cp.restore') restore.restore_from_current_file() elseif cmd.type == 'action' then - local CONTEST_ACTIONS = - { 'run', 'panel', 'edit', 'interact', 'stress', 'submit', 'next', 'prev', 'pick' } - if vim.tbl_contains(CONTEST_ACTIONS, cmd.action) and not state.get_platform() then + if cmd.requires_context and not state.get_platform() then local restore = require('cp.restore') if not restore.restore_from_current_file() then return diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 1609e61..a954d62 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -477,9 +477,15 @@ function M.navigate_problem(direction, language) logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id)) + local views = require('cp.ui.views') + views.cancel_io_view() local active_panel = state.get_active_panel() if active_panel == 'run' then - require('cp.ui.views').disable() + views.disable() + elseif active_panel == 'interactive' then + views.cancel_interactive() + elseif active_panel == 'stress' then + require('cp.stress').cancel() end local lang = nil diff --git a/plugin/cp.lua b/plugin/cp.lua index 60efb7a..3e3b56c 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -44,7 +44,12 @@ end, { table.insert(candidates, 'cache') table.insert(candidates, 'pick') if platform and contest_id then - vim.list_extend(candidates, actions) + vim.list_extend( + candidates, + vim.tbl_filter(function(a) + return a ~= 'pick' and a ~= 'cache' + end, actions) + ) local cache = require('cp.cache') cache.load() local contest_data = cache.get_contest_data(platform, contest_id) From b959f29bd421b60cd27fcdd316672119eabb071b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:00:35 -0500 Subject: [PATCH 087/114] fix(scrapers): harden CSES and CF submit edge cases (#295) (#318) ## Problem CSES `_web_login` used bare dict indexing on the API response, raising an opaque `KeyError` on missing fields. `_check_token` swallowed all exceptions as `False`, conflating transient network errors with invalid tokens. CF persisted cookies unconditionally and silently swallowed `_solve_turnstile` failures in `submit_action`. ## Solution CSES API fields now use `.get()` with a descriptive `RuntimeError` on absence. `_check_token` re-raises `httpx` network/timeout exceptions so callers see real failures. CF cookie writes are guarded by an `X-User-Handle` check (the CF auth cookie). `_solve_turnstile` errors propagate to the outer error handler instead of being silenced. --- scrapers/codeforces.py | 10 ++++------ scrapers/cses.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 7a96483..43d7328 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -401,7 +401,8 @@ def _login_headless_cf(credentials: dict[str, str]) -> LoginResult: try: browser_cookies = session.context.cookies() - cookie_cache.write_text(json.dumps(browser_cookies)) + if any(c.get("name") == "X-User-Handle" for c in browser_cookies): + cookie_cache.write_text(json.dumps(browser_cookies)) except Exception: pass @@ -478,10 +479,7 @@ def _submit_headless( if "/enter" in page.url or "/login" in page.url: needs_relogin = True return - try: - _solve_turnstile(page) - except Exception: - pass + _solve_turnstile(page) try: page.select_option( 'select[name="submittedProblemIndex"]', @@ -550,7 +548,7 @@ def _submit_headless( try: browser_cookies = session.context.cookies() - if browser_cookies: + if any(c.get("name") == "X-User-Handle" for c in browser_cookies): cookie_cache.write_text(json.dumps(browser_cookies)) except Exception: pass diff --git a/scrapers/cses.py b/scrapers/cses.py index ef5deda..cbf76e2 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -352,8 +352,12 @@ class CSESScraper(BaseScraper): f"{API_URL}/login", headers=HEADERS, timeout=HTTP_TIMEOUT ) api_data = api_resp.json() - token: str = api_data["X-Auth-Token"] - auth_url: str = api_data["authentication_url"] + token: str | None = api_data.get("X-Auth-Token") + auth_url: str | None = api_data.get("authentication_url") + if not token: + raise RuntimeError("CSES API login response missing 'X-Auth-Token'") + if not auth_url: + raise RuntimeError("CSES API login response missing 'authentication_url'") auth_page = await client.get(auth_url, headers=HEADERS, timeout=HTTP_TIMEOUT) auth_csrf = re.search(r'name="csrf_token" value="([^"]+)"', auth_page.text) @@ -388,8 +392,8 @@ class CSESScraper(BaseScraper): timeout=HTTP_TIMEOUT, ) return r.status_code == 200 - except Exception: - return False + except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError): + raise async def submit( self, From 7bf4cf2538327200204b61c0db3e2b55acbcac50 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:16:05 -0500 Subject: [PATCH 088/114] feat(commands): implement :CP open [problem|contest|standings] (#319) ## Problem There was no way to open a problem, contest, or standings page in the browser from within the plugin. ## Solution Add `contest_url` and `standings_url` to `MetadataResult` and persist them in the cache. Add `cache.get_open_urls` to resolve all three URLs. Wire up `:CP open [problem|contest|standings]` in `commands/init.lua` to call `vim.ui.open`, warning when a URL is unavailable (e.g. CSES has no standings). Closes #315. --- lua/cp/cache.lua | 29 ++++++++++++++++++++++++++++- lua/cp/commands/init.lua | 20 ++++++++++++++++++++ lua/cp/constants.lua | 1 + lua/cp/setup.lua | 9 ++++++++- scrapers/atcoder.py | 2 ++ scrapers/codeforces.py | 2 ++ scrapers/cses.py | 2 ++ scrapers/models.py | 2 ++ 8 files changed, 65 insertions(+), 2 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index eb82afa..3370532 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -10,6 +10,8 @@ ---@field name string ---@field display_name string ---@field url string +---@field contest_url string +---@field standings_url string ---@class ContestSummary ---@field display_name string @@ -148,12 +150,16 @@ end ---@param contest_id string ---@param problems Problem[] ---@param url string -function M.set_contest_data(platform, contest_id, problems, url) +---@param contest_url string +---@param standings_url string +function M.set_contest_data(platform, contest_id, problems, url, contest_url, standings_url) vim.validate({ platform = { platform, 'string' }, contest_id = { contest_id, 'string' }, problems = { problems, 'table' }, url = { url, 'string' }, + contest_url = { contest_url, 'string' }, + standings_url = { standings_url, 'string' }, }) cache_data[platform] = cache_data[platform] or {} @@ -165,6 +171,8 @@ function M.set_contest_data(platform, contest_id, problems, url) problems = problems, index_map = {}, url = url, + contest_url = contest_url, + standings_url = standings_url, } for i, p in ipairs(out.problems) do out.index_map[p.id] = i @@ -174,6 +182,25 @@ function M.set_contest_data(platform, contest_id, problems, url) M.save() end +---@param platform string? +---@param contest_id string? +---@param problem_id string? +---@return { problem: string|nil, contest: string|nil, standings: string|nil }|nil +function M.get_open_urls(platform, contest_id, problem_id) + if not platform or not contest_id then + return nil + end + if not cache_data[platform] or not cache_data[platform][contest_id] then + return nil + end + local cd = cache_data[platform][contest_id] + return { + problem = cd.url ~= '' and problem_id and string.format(cd.url, problem_id) or nil, + contest = cd.contest_url ~= '' and cd.contest_url or nil, + standings = cd.standings_url ~= '' and cd.standings_url or nil, + } +end + ---@param platform string ---@param contest_id string function M.clear_contest_data(platform, contest_id) diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 5006cc9..6570748 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -245,6 +245,12 @@ local function parse_command(args) debug = debug, mode = mode, } + elseif first == 'open' then + local target = args[2] or 'problem' + if not vim.tbl_contains({ 'problem', 'contest', 'standings' }, target) then + return { type = 'error', message = 'Usage: :CP open [problem|contest|standings]' } + end + return { type = 'action', action = 'open', requires_context = true, subcommand = target } elseif first == 'pick' then local language = nil if #args >= 3 and args[2] == '--lang' then @@ -375,6 +381,20 @@ function M.handle_command(opts) require('cp.race').start(cmd.platform, cmd.contest, cmd.language) elseif cmd.action == 'race_stop' then require('cp.race').stop() + elseif cmd.action == 'open' then + local cache = require('cp.cache') + cache.load() + local urls = + cache.get_open_urls(state.get_platform(), state.get_contest_id(), state.get_problem_id()) + local url = urls and urls[cmd.subcommand] + if not url or url == '' then + logger.log( + ("No URL available for '%s'"):format(cmd.subcommand), + { level = vim.log.levels.WARN } + ) + return + end + vim.ui.open(url) elseif cmd.action == 'login' then require('cp.credentials').login(cmd.platform) elseif cmd.action == 'logout' then diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index 21e8f62..b0dad5b 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -13,6 +13,7 @@ M.ACTIONS = { 'race', 'stress', 'submit', + 'open', } M.PLATFORM_DISPLAY_NAMES = { diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index a954d62..09b1f3b 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -243,7 +243,14 @@ function M.setup_contest(platform, contest_id, problem_id, language) contest_id, vim.schedule_wrap(function(result) local problems = result.problems or {} - cache.set_contest_data(platform, contest_id, problems, result.url) + cache.set_contest_data( + platform, + contest_id, + problems, + result.url, + result.contest_url or '', + result.standings_url or '' + ) local prov = state.get_provisional() if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then return diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index 966940a..b25ea7e 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -606,6 +606,8 @@ class AtcoderScraper(BaseScraper): contest_id=contest_id, problems=problems, url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s", + contest_url=f"https://atcoder.jp/contests/{contest_id}", + standings_url=f"https://atcoder.jp/contests/{contest_id}/standings", ) except Exception as e: return self._metadata_error(str(e)) diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 43d7328..d2e7083 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -223,6 +223,8 @@ class CodeforcesScraper(BaseScraper): contest_id=contest_id, problems=problems, url=f"https://codeforces.com/contest/{contest_id}/problem/%s", + contest_url=f"https://codeforces.com/contest/{contest_id}", + standings_url=f"https://codeforces.com/contest/{contest_id}/standings", ) except Exception as e: return self._metadata_error(str(e)) diff --git a/scrapers/cses.py b/scrapers/cses.py index cbf76e2..bd29af4 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -218,6 +218,8 @@ class CSESScraper(BaseScraper): contest_id=contest_id, problems=problems, url="https://cses.fi/problemset/task/%s", + contest_url="https://cses.fi/problemset", + standings_url="", ) async def scrape_contest_list(self) -> ContestListResult: diff --git a/scrapers/models.py b/scrapers/models.py index 4dafc64..2d579cb 100644 --- a/scrapers/models.py +++ b/scrapers/models.py @@ -42,6 +42,8 @@ class MetadataResult(ScrapingResult): contest_id: str = "" problems: list[ProblemSummary] = Field(default_factory=list) url: str + contest_url: str = "" + standings_url: str = "" model_config = ConfigDict(extra="forbid") From 401afb205e37337480861905fce7a9fbe81408ff Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:51:06 -0500 Subject: [PATCH 089/114] feat(commands): document :CP open [problem|contest|standings] (#321) forgot to document `:CP open` --- doc/cp.nvim.txt | 49 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 2c0cc5c..94e4762 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -24,15 +24,16 @@ CONTENTS *cp-contents* 16. Race .......................................................... |cp-race| 17. Credentials ............................................ |cp-credentials| 18. Submit ...................................................... |cp-submit| - 19. ANSI Colors ................................................... |cp-ansi| - 20. Highlight Groups ........................................ |cp-highlights| - 21. Terminal Colors .................................... |cp-terminal-colors| - 22. Highlight Customization .......................... |cp-highlight-custom| - 23. Helpers .................................................... |cp-helpers| - 24. Statusline Integration .................................. |cp-statusline| - 25. Panel Keymaps .......................................... |cp-panel-keys| - 26. File Structure ................................................ |cp-files| - 27. Health Check ................................................ |cp-health| + 19. Open ......................................................... |cp-open| + 20. ANSI Colors ................................................... |cp-ansi| + 21. Highlight Groups ........................................ |cp-highlights| + 22. Terminal Colors .................................... |cp-terminal-colors| + 23. Highlight Customization .......................... |cp-highlight-custom| + 24. Helpers .................................................... |cp-helpers| + 25. Statusline Integration .................................. |cp-statusline| + 26. Panel Keymaps .......................................... |cp-panel-keys| + 27. File Structure ................................................ |cp-files| + 28. Health Check ................................................ |cp-health| ============================================================================== INTRODUCTION *cp.nvim* @@ -487,6 +488,17 @@ COMMANDS *cp-commands* credentials are saved. --lang: Submit solution for a specific language. + :CP open [problem|contest|standings] + Open the URL for the current problem, contest, + or standings page in the browser via + |vim.ui.open|. Defaults to "problem" if no + argument is given. Warns if the URL is not + available (e.g. CSES has no standings). + Examples: > + :CP open + :CP open contest + :CP open standings +< State Restoration ~ :CP Restore state from current file. Automatically detects platform, contest, problem, @@ -580,6 +592,9 @@ through the same code path as |:CP|. *(cp-submit)* (cp-submit) Submit current solution. Equivalent to :CP submit. + *(cp-open)* +(cp-open) Open current problem URL in browser. Equivalent to :CP open. + *(cp-race-stop)* (cp-race-stop) Cancel active race countdown. Equivalent to :CP race stop. @@ -1021,6 +1036,22 @@ Submit the current solution to the online judge. AtCoder Fully implemented. Others Not yet implemented. +============================================================================== +OPEN *cp-open* + +Open a platform URL for the current contest in the browser. + +:CP open [problem|contest|standings] + Open the URL for the active problem, contest page, or standings. + Defaults to "problem" if no argument is given. Uses |vim.ui.open|. + Warns if the URL is unavailable (e.g. CSES has no standings page). + + Platform support: + AtCoder problem, contest, standings + Codeforces problem, contest, standings + CSES problem, contest (no standings) + Others Not yet implemented. + ============================================================================== ANSI COLORS AND HIGHLIGHTING *cp-ansi* From 2d50f0a52a753b9fd0a43cbb68b1fb9a099717c1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:55:43 -0500 Subject: [PATCH 090/114] refactor: remove open_url config option in favour of :CP open (#322) ## Problem `open_url` automatically opened the browser on contest load and problem change, which is now redundant with `:CP open`. ## Solution Remove the `open_url` field from `cp.Config`, its default, its validation, and the call site in `setup_contest`. Remove documentation from `cp.nvim.txt`. --- doc/cp.nvim.txt | 3 --- lua/cp/config.lua | 3 --- lua/cp/setup.lua | 8 -------- 3 files changed, 14 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 94e4762..c45b11c 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -113,7 +113,6 @@ Configuration is done via `vim.g.cp`. Set this before using the plugin: default_language = 'cpp', }, }, - open_url = true, debug = false, ui = { ansi = true, @@ -186,8 +185,6 @@ run CSES problems with Rust using the single schema: Should return full filename with extension. (default: concatenates contest_id and problem_id, lowercased) {ui} (|CpUI|) UI settings: panel, diff backend, picker. - {open_url} (boolean) Open the contest & problem url in the browser - when a new contest is opened or the active problem changes. *CpPlatform* Fields: ~ diff --git a/lua/cp/config.lua b/lua/cp/config.lua index ec6d75b..0593638 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -101,7 +101,6 @@ ---@field templates? CpTemplatesConfig ---@field hooks Hooks ---@field debug boolean ----@field open_url boolean ---@field scrapers string[] ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@field ui CpUI @@ -118,7 +117,6 @@ local utils = require('cp.utils') -- defaults per the new single schema ---@type cp.Config M.defaults = { - open_url = false, languages = { cpp = { extension = 'cc', @@ -362,7 +360,6 @@ function M.setup(user_config) hooks = { cfg.hooks, { 'table' } }, ui = { cfg.ui, { 'table' } }, debug = { cfg.debug, { 'boolean', 'nil' }, true }, - open_url = { cfg.open_url, { 'boolean', 'nil' }, true }, filename = { cfg.filename, { 'function', 'nil' }, true }, scrapers = { cfg.scrapers, diff --git a/lua/cp/setup.lua b/lua/cp/setup.lua index 09b1f3b..92eed89 100644 --- a/lua/cp/setup.lua +++ b/lua/cp/setup.lua @@ -164,7 +164,6 @@ end ---@param language? string function M.setup_contest(platform, contest_id, problem_id, language) local old_platform, old_contest_id = state.get_platform(), state.get_contest_id() - local old_problem_id = state.get_problem_id() state.set_platform(platform) state.set_contest_id(contest_id) @@ -205,13 +204,6 @@ function M.setup_contest(platform, contest_id, problem_id, language) local pid = problem_id and problem_id or problems[1].id M.setup_problem(pid, language) start_tests(platform, contest_id, problems) - - local is_new_problem = old_problem_id ~= pid - local should_open_url = config_module.get_config().open_url - and (is_new_contest or is_new_problem) - if should_open_url and contest_data.url then - vim.ui.open(contest_data.url:format(pid)) - end end local contest_data = cache.get_contest_data(platform, contest_id) From 543480a4fe0b5a4e2c38f3baedbd935797113dcd Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 00:09:16 -0500 Subject: [PATCH 091/114] feat: implement login and submit for USACO, Kattis, and CodeChef (#325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Login and submit were stub-only for USACO, Kattis, and CodeChef, leaving three platforms without a working solve loop. ## Solution Three commits, one per platform: **USACO** — httpx-based login via `login-session.php` with cookie cache. Submit fetches the problem page and parses the form dynamically (action URL, hidden fields, language select) before POSTing multipart with `sub_file[]`. **Kattis** — httpx-based login via `/login/email` (official Kattis CLI API). Submit is a multipart POST to `/submit`; the `contest` field is included only when `contest_id != problem_id`. `scrape_contest_list` URL updated to filter `kattis_original=on&kattis_recycled=off&user_created=off`. **CodeChef** — StealthySession browser-based login and submit following the AtCoder/CF pattern. Login checks `__NEXT_DATA__` for current user and fills the email/password form. Submit navigates to `/{contest_id}/submit/{problem_id}`, selects language (standard `', + body, + re.DOTALL | re.IGNORECASE, + ): + name, sel_body = sel_m.group(1), sel_m.group(2) + if "lang" in name.lower(): + lang_val = _pick_lang_option(sel_body, language_id) + break + break + return form_action, hidden, lang_val + + +async def _load_usaco_cookies(client: httpx.AsyncClient) -> None: + if not _COOKIE_PATH.exists(): + return + try: + for k, v in json.loads(_COOKIE_PATH.read_text()).items(): + client.cookies.set(k, v) + except Exception: + pass + + +async def _save_usaco_cookies(client: httpx.AsyncClient) -> None: + cookies = {k: v for k, v in client.cookies.items()} + if cookies: + _COOKIE_PATH.parent.mkdir(parents=True, exist_ok=True) + _COOKIE_PATH.write_text(json.dumps(cookies)) + + +async def _check_usaco_login(client: httpx.AsyncClient, username: str) -> bool: + try: + r = await client.get( + f"{_AUTH_BASE}/index.php", + headers=HEADERS, + timeout=HTTP_TIMEOUT, + ) + text = r.text.lower() + return username.lower() in text or "logout" in text + except Exception: + return False + + +async def _do_usaco_login( + client: httpx.AsyncClient, username: str, password: str +) -> bool: + r = await client.post( + f"{_AUTH_BASE}{_LOGIN_PATH}", + data={"user": username, "password": password}, + headers=HEADERS, + timeout=HTTP_TIMEOUT, + ) + r.raise_for_status() + try: + data = r.json() + return bool(data.get("success") or data.get("status") == "success") + except Exception: + return r.status_code == 200 and "error" not in r.text.lower() + + class USACOScraper(BaseScraper): @property def platform_name(self) -> str: @@ -293,15 +409,99 @@ class USACOScraper(BaseScraper): language_id: str, credentials: dict[str, str], ) -> SubmitResult: - return SubmitResult( - success=False, - error="USACO submit not yet implemented", - submission_id="", - verdict="", - ) + source = Path(file_path).read_bytes() + username = credentials.get("username", "") + password = credentials.get("password", "") + if not username or not password: + return self._submit_error("Missing credentials. Use :CP usaco login") + + async with httpx.AsyncClient(follow_redirects=True) as client: + await _load_usaco_cookies(client) + print(json.dumps({"status": "checking_login"}), flush=True) + logged_in = bool(client.cookies) and await _check_usaco_login( + client, username + ) + if not logged_in: + print(json.dumps({"status": "logging_in"}), flush=True) + try: + ok = await _do_usaco_login(client, username, password) + except Exception as e: + return self._submit_error(f"Login failed: {e}") + if not ok: + return self._submit_error("Login failed (bad credentials?)") + await _save_usaco_cookies(client) + + print(json.dumps({"status": "submitting"}), flush=True) + try: + page_r = await client.get( + f"{_AUTH_BASE}/index.php?page=viewproblem2&cpid={problem_id}", + headers=HEADERS, + timeout=HTTP_TIMEOUT, + ) + form_url, hidden_fields, lang_val = _parse_submit_form( + page_r.text, language_id + ) + except Exception: + form_url = _AUTH_BASE + _SUBMIT_PATH + hidden_fields = {} + lang_val = None + + data: dict[str, str] = {"cpid": problem_id, **hidden_fields} + data["language"] = lang_val if lang_val is not None else language_id + ext = "py" if "python" in language_id.lower() else "cpp" + try: + r = await client.post( + form_url, + data=data, + files={"sub_file[]": (f"solution.{ext}", source, "text/plain")}, + headers=HEADERS, + timeout=HTTP_TIMEOUT, + ) + r.raise_for_status() + except Exception as e: + return self._submit_error(f"Submit request failed: {e}") + + try: + resp = r.json() + sid = str(resp.get("submission_id", resp.get("id", ""))) + except Exception: + sid = "" + return SubmitResult( + success=True, error="", submission_id=sid, verdict="submitted" + ) async def login(self, credentials: dict[str, str]) -> LoginResult: - return self._login_error("USACO login not yet implemented") + username = credentials.get("username", "") + password = credentials.get("password", "") + if not username or not password: + return self._login_error("Missing username or password") + + async with httpx.AsyncClient(follow_redirects=True) as client: + await _load_usaco_cookies(client) + if client.cookies: + print(json.dumps({"status": "checking_login"}), flush=True) + if await _check_usaco_login(client, username): + return LoginResult( + success=True, + error="", + credentials={"username": username, "password": password}, + ) + + print(json.dumps({"status": "logging_in"}), flush=True) + try: + ok = await _do_usaco_login(client, username, password) + except Exception as e: + return self._login_error(f"Login request failed: {e}") + + if not ok: + return self._login_error("Login failed (bad credentials?)") + + await _save_usaco_cookies(client) + return LoginResult( + success=True, + error="", + credentials={"username": username, "password": password}, + ) if __name__ == "__main__": From b6d3df03e38bc6c15c2bb2d400698ca4531d588e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:38:32 -0500 Subject: [PATCH 092/114] fix(scrapers): fix Kattis and USACO login and submit (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Kattis and USACO login and submit were broken in multiple ways discovered during manual end-to-end testing. Neither platform could successfully authenticate or submit through the plugin. ## Solution **Kattis:** switch login from `POST /login/email` (requires CSRF fetch) to `POST /login` with `script=true` (200 = success, 403 = bad credentials); remove `_check_kattis_login` entirely since Kattis blocks all GET requests from httpx; add submit retry on `"Request validation failed"` to handle expired sessions; fix language ID `"C++17"` → `"C++"`. **USACO:** fix login field `user` → `uname`; fix success check to `code==1`; fix submit endpoint to `submit-solution.php`, file field to `sourcefile`, hidden field extraction off-by-one (`group(2)` → `group(1)`); fix `_pick_lang_option` loop order (keywords outer, options inner) so specific keywords like `"c++17"` match before broad ones like `"c++"`. **`submit.lua`:** absolutize source file path via `fnamemodify(..., ':p')` before passing to the scraper — Python is spawned with `cwd=plugin_path` so relative paths silently fail with `FileNotFoundError`. **Both platforms:** remove cookie fast path from `login` subcommand so credentials are always validated, preventing stale cookies from masking wrong credentials. --- lua/cp/submit.lua | 1 + scrapers/kattis.py | 50 ++++++++++++++++++---------------------- scrapers/language_ids.py | 2 +- scrapers/usaco.py | 42 ++++++++++++++------------------- 4 files changed, 42 insertions(+), 53 deletions(-) diff --git a/lua/cp/submit.lua b/lua/cp/submit.lua index 77e3d66..50d884f 100644 --- a/lua/cp/submit.lua +++ b/lua/cp/submit.lua @@ -54,6 +54,7 @@ function M.submit(opts) logger.log('Source file not found', { level = vim.log.levels.ERROR }) return end + source_file = vim.fn.fnamemodify(source_file, ':p') prompt_credentials(platform, function(creds) vim.cmd.update() diff --git a/scrapers/kattis.py b/scrapers/kattis.py index 43ce1f3..734705e 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -221,25 +221,17 @@ async def _save_kattis_cookies(client: httpx.AsyncClient) -> None: _COOKIE_PATH.write_text(json.dumps(cookies)) -async def _check_kattis_login(client: httpx.AsyncClient) -> bool: - try: - r = await client.get(BASE_URL + "/", headers=HEADERS, timeout=HTTP_TIMEOUT) - text = r.text.lower() - return "sign out" in text or "logout" in text or "my profile" in text - except Exception: - return False - - async def _do_kattis_login( client: httpx.AsyncClient, username: str, password: str ) -> bool: + client.cookies.clear() r = await client.post( - f"{BASE_URL}/login/email", + f"{BASE_URL}/login", data={"user": username, "password": password, "script": "true"}, headers=HEADERS, timeout=HTTP_TIMEOUT, ) - return r.status_code == 200 and "login failed" not in r.text.lower() + return r.status_code == 200 class KattisScraper(BaseScraper): @@ -330,9 +322,7 @@ class KattisScraper(BaseScraper): async with httpx.AsyncClient(follow_redirects=True) as client: await _load_kattis_cookies(client) - print(json.dumps({"status": "checking_login"}), flush=True) - logged_in = bool(client.cookies) and await _check_kattis_login(client) - if not logged_in: + if not client.cookies: print(json.dumps({"status": "logging_in"}), flush=True) ok = await _do_kattis_login(client, username, password) if not ok: @@ -351,18 +341,35 @@ class KattisScraper(BaseScraper): } if contest_id != problem_id: data["contest"] = contest_id - try: - r = await client.post( + + async def _do_submit() -> httpx.Response: + return await client.post( f"{BASE_URL}/submit", data=data, files={"sub_file[]": (f"solution.{ext}", source, "text/plain")}, headers=HEADERS, timeout=HTTP_TIMEOUT, ) + + try: + r = await _do_submit() r.raise_for_status() except Exception as e: return self._submit_error(f"Submit request failed: {e}") + if r.text == "Request validation failed": + _COOKIE_PATH.unlink(missing_ok=True) + print(json.dumps({"status": "logging_in"}), flush=True) + ok = await _do_kattis_login(client, username, password) + if not ok: + return self._submit_error("Login failed (bad credentials?)") + await _save_kattis_cookies(client) + try: + r = await _do_submit() + r.raise_for_status() + except Exception as e: + return self._submit_error(f"Submit request failed: {e}") + sid_m = re.search(r"Submission ID:\s*(\d+)", r.text, re.IGNORECASE) sid = sid_m.group(1) if sid_m else "" return SubmitResult( @@ -376,21 +383,10 @@ class KattisScraper(BaseScraper): return self._login_error("Missing username or password") async with httpx.AsyncClient(follow_redirects=True) as client: - await _load_kattis_cookies(client) - if client.cookies: - print(json.dumps({"status": "checking_login"}), flush=True) - if await _check_kattis_login(client): - return LoginResult( - success=True, - error="", - credentials={"username": username, "password": password}, - ) - print(json.dumps({"status": "logging_in"}), flush=True) ok = await _do_kattis_login(client, username, password) if not ok: return self._login_error("Login failed (bad credentials?)") - await _save_kattis_cookies(client) return LoginResult( success=True, diff --git a/scrapers/language_ids.py b/scrapers/language_ids.py index 6870aa3..1abfcc6 100644 --- a/scrapers/language_ids.py +++ b/scrapers/language_ids.py @@ -16,7 +16,7 @@ LANGUAGE_IDS = { "python": "python", }, "kattis": { - "cpp": "C++17", + "cpp": "C++", "python": "Python 3", }, "codechef": { diff --git a/scrapers/usaco.py b/scrapers/usaco.py index 73ec6b1..074cbf9 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -29,7 +29,7 @@ CONNECTIONS = 4 _COOKIE_PATH = Path.home() / ".cache" / "cp-nvim" / "usaco-cookies.json" _LOGIN_PATH = "/current/tpcm/login-session.php" -_SUBMIT_PATH = "/current/tpcm/submitproblem.php" +_SUBMIT_PATH = "/current/tpcm/submit-solution.php" _LANG_KEYWORDS: dict[str, list[str]] = { "cpp": ["c++17", "c++ 17", "g++17", "c++", "cpp"], @@ -141,13 +141,16 @@ def _parse_problem_page(html: str) -> dict[str, Any]: def _pick_lang_option(select_body: str, language_id: str) -> str | None: keywords = _LANG_KEYWORDS.get(language_id.lower(), [language_id.lower()]) - for m in re.finditer( - r']*\bvalue=["\']([^"\']*)["\'][^>]*>([^<]+)', - select_body, - re.IGNORECASE, - ): - val, text = m.group(1), m.group(2).strip().lower() - for kw in keywords: + options = [ + (m.group(1), m.group(2).strip().lower()) + for m in re.finditer( + r']*\bvalue=["\']([^"\']*)["\'][^>]*>([^<]+)', + select_body, + re.IGNORECASE, + ) + ] + for kw in keywords: + for val, text in options: if kw in text: return val return None @@ -165,7 +168,7 @@ def _parse_submit_form( re.DOTALL | re.IGNORECASE, ): action, body = form_m.group(1), form_m.group(2) - if "sub_file" not in body.lower(): + if "sourcefile" not in body.lower(): continue if action.startswith("http"): form_action = action @@ -182,7 +185,7 @@ def _parse_submit_form( name_m = re.search(r'\bname=["\']([^"\']+)["\']', tag, re.IGNORECASE) val_m = re.search(r'\bvalue=["\']([^"\']*)["\']', tag, re.IGNORECASE) if name_m and val_m: - hidden[name_m.group(1)] = val_m.group(2) + hidden[name_m.group(1)] = val_m.group(1) for sel_m in re.finditer( r']*\bname=["\']([^"\']+)["\'][^>]*>(.*?)', body, @@ -231,16 +234,15 @@ async def _do_usaco_login( ) -> bool: r = await client.post( f"{_AUTH_BASE}{_LOGIN_PATH}", - data={"user": username, "password": password}, + data={"uname": username, "password": password}, headers=HEADERS, timeout=HTTP_TIMEOUT, ) r.raise_for_status() try: - data = r.json() - return bool(data.get("success") or data.get("status") == "success") + return r.json().get("code") == 1 except Exception: - return r.status_code == 200 and "error" not in r.text.lower() + return False class USACOScraper(BaseScraper): @@ -453,7 +455,7 @@ class USACOScraper(BaseScraper): r = await client.post( form_url, data=data, - files={"sub_file[]": (f"solution.{ext}", source, "text/plain")}, + files={"sourcefile": (f"solution.{ext}", source, "text/plain")}, headers=HEADERS, timeout=HTTP_TIMEOUT, ) @@ -477,16 +479,6 @@ class USACOScraper(BaseScraper): return self._login_error("Missing username or password") async with httpx.AsyncClient(follow_redirects=True) as client: - await _load_usaco_cookies(client) - if client.cookies: - print(json.dumps({"status": "checking_login"}), flush=True) - if await _check_usaco_login(client, username): - return LoginResult( - success=True, - error="", - credentials={"username": username, "password": password}, - ) - print(json.dumps({"status": "logging_in"}), flush=True) try: ok = await _do_usaco_login(client, username, password) From ba5ae8df69c682e9ae6e5c358b483bd860d446a2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:25:17 -0500 Subject: [PATCH 093/114] fix(kattis): fix nil display_name in contest picker (#332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `ContestSummary.display_name` defaults to `None`, which serializes to JSON `null` → Lua `vim.NIL`. The contest picker displayed "vim.NIL" for every entry. Additionally, `start_time` was always stored even when null, because `vim.NIL` is truthy in Lua. ## Solution Pass `display_name=name` explicitly in `_parse_contests_page` so JSON never emits `null`. In `cache.lua` `set_contest_summaries`, coerce `display_name` via a `~= vim.NIL` guard and apply the same guard before storing `start_time`. --- lua/cp/cache.lua | 6 ++++-- scrapers/kattis.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lua/cp/cache.lua b/lua/cp/cache.lua index 3370532..987caee 100644 --- a/lua/cp/cache.lua +++ b/lua/cp/cache.lua @@ -396,9 +396,11 @@ function M.set_contest_summaries(platform, contests) cache_data[platform] = cache_data[platform] or {} for _, contest in ipairs(contests) do cache_data[platform][contest.id] = cache_data[platform][contest.id] or {} - cache_data[platform][contest.id].display_name = contest.display_name + cache_data[platform][contest.id].display_name = ( + contest.display_name ~= vim.NIL and contest.display_name + ) or contest.name cache_data[platform][contest.id].name = contest.name - if contest.start_time then + if contest.start_time and contest.start_time ~= vim.NIL then cache_data[platform][contest.id].start_time = contest.start_time end end diff --git a/scrapers/kattis.py b/scrapers/kattis.py index 734705e..7741847 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -123,7 +123,9 @@ def _parse_contests_page(html: str) -> list[ContestSummary]: start_time = int(dt.timestamp()) except Exception: pass - results.append(ContestSummary(id=cid, name=name, start_time=start_time)) + results.append( + ContestSummary(id=cid, name=name, display_name=name, start_time=start_time) + ) return results From 6a3d2fe4f83c98803bd5fc1de2c754d488906311 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:25:31 -0500 Subject: [PATCH 094/114] fix(codechef): rewrite contest list, drop curl_cffi (#333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `scrape_contest_list` made O(N) requests — one per Starters number up to ~200 — to discover division sub-contests via `child_contests`. `run_one` also fetched problem HTML via `curl_cffi` solely to extract the memory limit, which is unavailable in the nix python env. ## Solution Use `/api/list/contests/all` directly: filter to `^START\d+$` codes and map to `ContestSummary` in a single request. Remove `_fetch_html_sync`, `MEMORY_LIMIT_RE`, and `_extract_memory_limit`; hardcode `memory_mb = 256.0` and `precision = None` in `run_one`. --- scrapers/codechef.py | 107 +++++++++++-------------------------------- 1 file changed, 28 insertions(+), 79 deletions(-) diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 2a48dd5..8f13eee 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -3,13 +3,13 @@ import asyncio import json import re +from datetime import datetime from pathlib import Path from typing import Any import httpx -from curl_cffi import requests as curl_requests -from .base import BaseScraper, extract_precision +from .base import BaseScraper from .timeouts import BROWSER_NAV_TIMEOUT, BROWSER_SESSION_TIMEOUT, HTTP_TIMEOUT from .models import ( ContestListResult, @@ -25,7 +25,6 @@ BASE_URL = "https://www.codechef.com" API_CONTESTS_ALL = "/api/list/contests/all" API_CONTEST = "/api/contests/{contest_id}" API_PROBLEM = "/api/contests/{contest_id}/problems/{problem_id}" -PROBLEM_URL = "https://www.codechef.com/problems/{problem_id}" HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } @@ -44,9 +43,6 @@ _CC_CHECK_LOGIN_JS = """() => { return !!document.querySelector('a[href="/logout"]') || !!document.querySelector('[class*="user-name"]'); }""" -MEMORY_LIMIT_RE = re.compile( - r"Memory\s+[Ll]imit.*?([0-9.]+)\s*(MB|GB)", re.IGNORECASE | re.DOTALL -) async def fetch_json(client: httpx.AsyncClient, path: str) -> dict[str, Any]: @@ -55,23 +51,6 @@ async def fetch_json(client: httpx.AsyncClient, path: str) -> dict[str, Any]: return r.json() -def _extract_memory_limit(html: str) -> float: - m = MEMORY_LIMIT_RE.search(html) - if not m: - return 256.0 - value = float(m.group(1)) - unit = m.group(2).upper() - if unit == "GB": - return value * 1024.0 - return value - - -def _fetch_html_sync(url: str) -> str: - response = curl_requests.get(url, impersonate="chrome", timeout=HTTP_TIMEOUT) - response.raise_for_status() - return response.text - - def _login_headless_codechef(credentials: dict[str, str]) -> LoginResult: try: from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] @@ -364,56 +343,31 @@ class CodeChefScraper(BaseScraper): data = await fetch_json(client, API_CONTESTS_ALL) except httpx.HTTPStatusError as e: return self._contests_error(f"Failed to fetch contests: {e}") - all_contests = data.get("future_contests", []) + data.get( - "past_contests", [] + contests: list[ContestSummary] = [] + seen: set[str] = set() + for c in data.get("future_contests", []) + data.get("past_contests", []): + code = c.get("contest_code", "") + name = c.get("contest_name", code) + if not re.match(r"^START\d+$", code): + continue + if code in seen: + continue + seen.add(code) + start_time: int | None = None + iso = c.get("contest_start_date_iso") + if iso: + try: + dt = datetime.fromisoformat(iso) + start_time = int(dt.timestamp()) + except Exception: + pass + contests.append( + ContestSummary( + id=code, name=name, display_name=name, start_time=start_time + ) ) - max_num = 0 - for contest in all_contests: - contest_code = contest.get("contest_code", "") - if contest_code.startswith("START"): - match = re.match(r"START(\d+)", contest_code) - if match: - num = int(match.group(1)) - max_num = max(max_num, num) - if max_num == 0: - return self._contests_error("No Starters contests found") - contests = [] - sem = asyncio.Semaphore(CONNECTIONS) - - async def fetch_divisions(i: int) -> list[ContestSummary]: - parent_id = f"START{i}" - async with sem: - try: - parent_data = await fetch_json( - client, API_CONTEST.format(contest_id=parent_id) - ) - except Exception as e: - import sys - - print(f"Error fetching {parent_id}: {e}", file=sys.stderr) - return [] - child_contests = parent_data.get("child_contests", {}) - if not child_contests: - return [] - base_name = f"Starters {i}" - divisions = [] - for div_key, div_data in child_contests.items(): - div_code = div_data.get("contest_code", "") - div_num = div_data.get("div", {}).get("div_number", "") - if div_code and div_num: - divisions.append( - ContestSummary( - id=div_code, - name=base_name, - display_name=f"{base_name} (Div. {div_num})", - ) - ) - return divisions - - tasks = [fetch_divisions(i) for i in range(1, max_num + 1)] - for coro in asyncio.as_completed(tasks): - divisions = await coro - contests.extend(divisions) + if not contests: + return self._contests_error("No Starters contests found") return ContestListResult(success=True, error="", contests=contests) async def stream_tests_for_category_async(self, category_id: str) -> None: @@ -481,14 +435,9 @@ class CodeChefScraper(BaseScraper): ] time_limit_str = problem_data.get("max_timelimit", "1") timeout_ms = int(float(time_limit_str) * 1000) - problem_url = PROBLEM_URL.format(problem_id=problem_code) - loop = asyncio.get_event_loop() - html = await loop.run_in_executor( - None, _fetch_html_sync, problem_url - ) - memory_mb = _extract_memory_limit(html) + memory_mb = 256.0 interactive = False - precision = extract_precision(html) + precision = None except Exception: tests = [] timeout_ms = 1000 From 0b40e0f33e7cf43dc045734493d5f64f9f00d7d6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:25:44 -0500 Subject: [PATCH 095/114] fix: replace curl_cffi with scrapling in codeforces metadata (#334) ## Problem `codeforces.py` used `curl_cffi` to bypass Cloudflare when fetching contest problem HTML, making it unavailable in the nix python env and requiring an extra dependency across `pyproject.toml` and `flake.nix`. ## Solution Rewrite `_fetch_problems_html` to use scrapling `StealthySession` with `solve_cloudflare=True`, matching the existing CF submit pattern. Extend `needs_browser` in `scraper.lua` to route CF `metadata` and `tests` through the FHS env on NixOS. Remove `curl-cffi` from `pyproject.toml`, `flake.nix`, and test mocks. --- flake.nix | 2 -- lua/cp/scraper.lua | 4 +++- pyproject.toml | 1 - scrapers/codeforces.py | 27 +++++++++++++++++++++++---- tests/conftest.py | 34 ++++++---------------------------- 5 files changed, 32 insertions(+), 36 deletions(-) diff --git a/flake.nix b/flake.nix index 1c416c6..1ccb2d3 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,6 @@ pkgs.python312.withPackages (ps: [ ps.backoff ps.beautifulsoup4 - ps.curl-cffi ps.httpx ps.ndjson ps.pydantic @@ -31,7 +30,6 @@ pkgs.python312.withPackages (ps: [ ps.backoff ps.beautifulsoup4 - ps.curl-cffi ps.httpx ps.ndjson ps.pydantic diff --git a/lua/cp/scraper.lua b/lua/cp/scraper.lua index 8f9759d..28705df 100644 --- a/lua/cp/scraper.lua +++ b/lua/cp/scraper.lua @@ -44,7 +44,9 @@ local function run_scraper(platform, subcommand, args, opts) return { success = false, error = msg } end - local needs_browser = subcommand == 'submit' or subcommand == 'login' + local needs_browser = subcommand == 'submit' + or subcommand == 'login' + or (platform == 'codeforces' and (subcommand == 'metadata' or subcommand == 'tests')) if needs_browser then utils.setup_nix_submit_env() diff --git a/pyproject.toml b/pyproject.toml index 9ffc00c..e677a57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "backoff>=2.2.1", "beautifulsoup4>=4.13.5", "scrapling[fetchers]>=0.4", - "curl-cffi>=0.13.0", "httpx>=0.28.1", "ndjson>=0.3.1", "pydantic>=2.11.10", diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index d2e7083..8ae768e 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -7,7 +7,6 @@ from typing import Any import requests from bs4 import BeautifulSoup, Tag -from curl_cffi import requests as curl_requests from .base import BaseScraper, extract_precision from .models import ( @@ -141,10 +140,30 @@ def _is_interactive(block: Tag) -> bool: def _fetch_problems_html(contest_id: str) -> str: + try: + from scrapling.fetchers import StealthySession # type: ignore[import-untyped,unresolved-import] + except ImportError: + raise RuntimeError("scrapling is required for Codeforces metadata") + + from .atcoder import _ensure_browser + + _ensure_browser() + url = f"{BASE_URL}/contest/{contest_id}/problems" - response = curl_requests.get(url, impersonate="chrome", timeout=HTTP_TIMEOUT) - response.raise_for_status() - return response.text + html = "" + + def page_action(page): + nonlocal html + html = page.content() + + with StealthySession( + headless=True, + timeout=BROWSER_SESSION_TIMEOUT, + google_search=False, + ) as session: + session.fetch(url, page_action=page_action, solve_cloudflare=True) + + return html def _parse_all_blocks(html: str) -> list[dict[str, Any]]: diff --git a/tests/conftest.py b/tests/conftest.py index deb7e3a..b6ff810 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,6 @@ from typing import Any import httpx import pytest import requests -from curl_cffi import requests as curl_requests ROOT = Path(__file__).resolve().parent.parent FIX = Path(__file__).resolve().parent / "fixtures" @@ -136,15 +135,10 @@ def run_scraper_offline(fixture_text): case "codeforces": - class MockCurlResponse: - def __init__(self, html: str): - self.text = html - - def raise_for_status(self): - pass - - def _mock_curl_get(url: str, **kwargs): - return MockCurlResponse(_router_codeforces(url=url)) + def _mock_fetch_problems_html(cid: str) -> str: + return _router_codeforces( + url=f"https://codeforces.com/contest/{cid}/problems" + ) def _mock_requests_get(url: str, **kwargs): if "api/contest.list" in url: @@ -175,7 +169,7 @@ def run_scraper_offline(fixture_text): raise AssertionError(f"Unexpected requests.get call: {url}") return { - "curl_requests.get": _mock_curl_get, + "_fetch_problems_html": _mock_fetch_problems_html, "requests.get": _mock_requests_get, } @@ -215,23 +209,8 @@ def run_scraper_offline(fixture_text): return MockResponse(data) raise AssertionError(f"No fixture for CodeChef url={url!r}") - class MockCodeChefCurlResponse: - def __init__(self, html: str): - self.text = html - - def raise_for_status(self): - pass - - def _mock_curl_get(url: str, **kwargs): - if "/problems/" in url: - problem_id = url.rstrip("/").split("/")[-1] - html = fixture_text(f"codechef/{problem_id}.html") - return MockCodeChefCurlResponse(html) - raise AssertionError(f"No fixture for CodeChef url={url!r}") - return { "__offline_get_async": __offline_get_async, - "curl_requests.get": _mock_curl_get, } case _: @@ -250,7 +229,7 @@ def run_scraper_offline(fixture_text): offline_fetches = _make_offline_fetches(scraper_name) if scraper_name == "codeforces": - curl_requests.get = offline_fetches["curl_requests.get"] + ns._fetch_problems_html = offline_fetches["_fetch_problems_html"] requests.get = offline_fetches["requests.get"] elif scraper_name == "atcoder": ns._fetch = offline_fetches["_fetch"] @@ -259,7 +238,6 @@ def run_scraper_offline(fixture_text): httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] elif scraper_name == "codechef": httpx.AsyncClient.get = offline_fetches["__offline_get_async"] - curl_requests.get = offline_fetches["curl_requests.get"] scraper_class = getattr(ns, scraper_classes[scraper_name]) scraper = scraper_class() From c4be8b4f9ee92b23104a7010fc9f19da651409aa Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 15:05:42 -0500 Subject: [PATCH 096/114] fix: remove curl_cffi --- uv.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/uv.lock b/uv.lock index d3334ab..1e3d25c 100644 --- a/uv.lock +++ b/uv.lock @@ -1034,7 +1034,6 @@ source = { virtual = "." } dependencies = [ { name = "backoff" }, { name = "beautifulsoup4" }, - { name = "curl-cffi" }, { name = "httpx" }, { name = "ndjson" }, { name = "pydantic" }, @@ -1058,7 +1057,6 @@ dev = [ requires-dist = [ { name = "backoff", specifier = ">=2.2.1" }, { name = "beautifulsoup4", specifier = ">=4.13.5" }, - { name = "curl-cffi", specifier = ">=0.13.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "ndjson", specifier = ">=0.3.1" }, { name = "pydantic", specifier = ">=2.11.10" }, From 82640709d6dcc884c5c0312070a1c457cd835a0d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:23:55 -0500 Subject: [PATCH 097/114] fix(kattis,usaco): precision, open URLs, and Kattis submit error surface (#335) ## Problem Kattis and USACO problem tests never extracted float precision, so epsilon problems got no tolerance. Kattis `scrape_contest_metadata` omitted `contest_url` and `standings_url`, breaking `:CP open contest/standings`. Kattis submit always returned success even when the server responded with an error (e.g. "You need to join the contest"). ## Solution Call `extract_precision` on problem HTML in both scrapers and emit it in the JSON payload. Set `contest_url` and `standings_url` on Kattis metadata paths. After Kattis submit, check for `Submission ID:` in the response and surface the error text if absent. --- scrapers/kattis.py | 18 +++++++++++++++--- scrapers/usaco.py | 6 +++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/scrapers/kattis.py b/scrapers/kattis.py index 7741847..2c5c1fc 100644 --- a/scrapers/kattis.py +++ b/scrapers/kattis.py @@ -10,7 +10,7 @@ from pathlib import Path import httpx -from .base import BaseScraper +from .base import BaseScraper, extract_precision from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, @@ -173,6 +173,7 @@ async def _stream_single_problem(client: httpx.AsyncClient, slug: str) -> None: timeout_ms, memory_mb = _parse_limits(html) interactive = _is_interactive(html) + precision = extract_precision(html) tests: list[TestCase] = [] try: @@ -200,6 +201,7 @@ async def _stream_single_problem(client: httpx.AsyncClient, slug: str) -> None: "memory_mb": memory_mb, "interactive": interactive, "multi_test": False, + "precision": precision, } ), flush=True, @@ -254,6 +256,8 @@ class KattisScraper(BaseScraper): ProblemSummary(id=slug, name=name) for slug, name in slugs ], url=f"{BASE_URL}/problems/%s", + contest_url=f"{BASE_URL}/contests/{contest_id}", + standings_url=f"{BASE_URL}/contests/{contest_id}/standings", ) try: html = await _fetch_text( @@ -273,6 +277,8 @@ class KattisScraper(BaseScraper): contest_id=contest_id, problems=[ProblemSummary(id=contest_id, name=name)], url=f"{BASE_URL}/problems/%s", + contest_url=f"{BASE_URL}/problems/{contest_id}", + standings_url="", ) except Exception as e: return self._metadata_error(str(e)) @@ -373,9 +379,15 @@ class KattisScraper(BaseScraper): return self._submit_error(f"Submit request failed: {e}") sid_m = re.search(r"Submission ID:\s*(\d+)", r.text, re.IGNORECASE) - sid = sid_m.group(1) if sid_m else "" + if not sid_m: + return self._submit_error( + r.text.strip() or "Submit failed (no submission ID)" + ) return SubmitResult( - success=True, error="", submission_id=sid, verdict="submitted" + success=True, + error="", + submission_id=sid_m.group(1), + verdict="submitted", ) async def login(self, credentials: dict[str, str]) -> LoginResult: diff --git a/scrapers/usaco.py b/scrapers/usaco.py index 074cbf9..5ab89f7 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -8,7 +8,7 @@ from typing import Any, cast import httpx -from .base import BaseScraper +from .base import BaseScraper, extract_precision from .timeouts import HTTP_TIMEOUT from .models import ( ContestListResult, @@ -130,12 +130,14 @@ def _parse_problem_page(html: str) -> dict[str, Any]: memory_mb = int(mm.group(1)) if mm else 256 interactive = "interactive problem" in html.lower() + precision = extract_precision(html) return { "tests": tests, "timeout_ms": timeout_ms, "memory_mb": memory_mb, "interactive": interactive, + "precision": precision, } @@ -375,6 +377,7 @@ class USACOScraper(BaseScraper): "timeout_ms": 4000, "memory_mb": 256, "interactive": False, + "precision": None, } tests = cast(list[TestCase], info["tests"]) @@ -396,6 +399,7 @@ class USACOScraper(BaseScraper): "memory_mb": info["memory_mb"], "interactive": info["interactive"], "multi_test": False, + "precision": info["precision"], } tasks = [run_one(cpid) for cpid, _ in problems_raw] From e89c57558d92fd901dc2134c3e6ef684e4a5f9f9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:14:16 -0500 Subject: [PATCH 098/114] feat(config): merge platform config model and add disabled-platform guard (#336) ## Problem Supplying any `platforms` table silently dropped all unlisted platforms, making it easy to accidentally disable platforms. Invoking a disabled platform produced no user-facing error. ## Solution Switch to a merge model: all six platforms are enabled by default and user entries are deep-merged on top. Set a platform key to `false` to disable it explicitly. Add a `check_platform_enabled` guard in `handle_command` for contest fetch, login, logout, and race actions. --- doc/cp.nvim.txt | 53 ++++++++++++++++++++-------------------- lua/cp/commands/init.lua | 40 +++++++++++++++++++++++++++++- lua/cp/config.lua | 10 ++++++-- 3 files changed, 74 insertions(+), 29 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index c45b11c..1db397b 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -92,26 +92,6 @@ Configuration is done via `vim.g.cp`. Set this before using the plugin: cpp = { extension = 'cpp', commands = { build = { ... } } } }, }, - atcoder = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', - }, - codeforces = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', - }, - codechef = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', - }, - usaco = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', - }, - kattis = { - enabled_languages = { 'cpp', 'python' }, - default_language = 'cpp', - }, }, debug = false, ui = { @@ -137,21 +117,25 @@ Configuration is done via `vim.g.cp`. Set this before using the plugin: < By default, C++ (g++ with ISO C++17) and Python are preconfigured under -'languages'. Platforms select which languages are enabled and which one is -the default; per-platform overrides can tweak 'extension' or 'commands'. +'languages'. All six platforms are enabled by default. User-supplied +platform entries are merged on top of the defaults — you only need to +specify what you want to change. To disable a platform entirely, set it +to `false`. -For example, to run CodeForces contests with Python by default: +For example, to run Codeforces contests with Python by default and +disable CodeChef: >lua vim.g.cp = { platforms = { codeforces = { default_language = 'python', }, + codechef = false, }, } < Any language is supported provided the proper configuration. For example, to -run CSES problems with Rust using the single schema: +add Rust and use it by default on CSES: >lua vim.g.cp = { languages = { @@ -175,8 +159,11 @@ run CSES problems with Rust using the single schema: Fields: ~ {languages} (table) Global language registry. Each language provides an {extension} and {commands}. - {platforms} (table) Per-platform enablement, - default language, and optional overrides. + {platforms} (table) All six platforms + are enabled by default. Each entry is merged on top + of the platform defaults — omitted fields keep their + defaults and unmentioned platforms stay enabled. Set + a platform key to `false` to disable it entirely. {hooks} (|cp.Hooks|) Hook functions called at various stages. {debug} (boolean, default: false) Show info messages. {scrapers} (string[]) Supported platform ids. @@ -476,6 +463,14 @@ COMMANDS *cp-commands* If [platform] is omitted, uses the active platform. Examples: > :CP logout atcoder +< + :CP {platform} signup + Open the platform's registration page in the + browser via |vim.ui.open|. Works even if + {platform} is not enabled in your config. + Examples: > + :CP atcoder signup + :CP codeforces signup < Submit Commands ~ :CP submit [--lang {language}] @@ -1019,6 +1014,12 @@ Credentials are stored under _credentials in the main cache file Remove stored credentials for a platform. Omit [platform] to use the currently active platform. +:CP {platform} signup + Open the platform's account registration page in the browser via + |vim.ui.open|. Works even if {platform} is not enabled in your + config. {platform} is one of: atcoder, codechef, codeforces, cses, + kattis, usaco. + ============================================================================== SUBMIT *cp-submit* diff --git a/lua/cp/commands/init.lua b/lua/cp/commands/init.lua index 6570748..f4368ce 100644 --- a/lua/cp/commands/init.lua +++ b/lua/cp/commands/init.lua @@ -283,7 +283,7 @@ local function parse_command(args) message = 'Too few arguments - specify a contest.', } elseif #args == 2 then - if args[2] == 'login' or args[2] == 'logout' then + if args[2] == 'login' or args[2] == 'logout' or args[2] == 'signup' then return { type = 'action', action = args[2], requires_context = false, platform = first } end local contest = args[2] @@ -330,6 +330,22 @@ local function parse_command(args) return { type = 'error', message = 'Unknown command or no contest context.' } end +---@param platform string +---@return boolean +local function check_platform_enabled(platform) + local cfg = require('cp.config').get_config() + if not cfg.platforms[platform] then + logger.log( + ("Platform '%s' is not enabled. Add it to vim.g.cp.platforms to enable it."):format( + constants.PLATFORM_DISPLAY_NAMES[platform] or platform + ), + { level = vim.log.levels.ERROR } + ) + return false + end + return true +end + --- Core logic for handling `:CP ...` commands ---@return nil function M.handle_command(opts) @@ -378,6 +394,9 @@ function M.handle_command(opts) elseif cmd.action == 'submit' then require('cp.submit').submit({ language = cmd.language }) elseif cmd.action == 'race' then + if not check_platform_enabled(cmd.platform) then + return + end require('cp.race').start(cmd.platform, cmd.contest, cmd.language) elseif cmd.action == 'race_stop' then require('cp.race').stop() @@ -396,9 +415,25 @@ function M.handle_command(opts) end vim.ui.open(url) elseif cmd.action == 'login' then + if not check_platform_enabled(cmd.platform) then + return + end require('cp.credentials').login(cmd.platform) elseif cmd.action == 'logout' then + if not check_platform_enabled(cmd.platform) then + return + end require('cp.credentials').logout(cmd.platform) + elseif cmd.action == 'signup' then + local url = constants.SIGNUP_URLS[cmd.platform] + if not url then + logger.log( + ("No signup URL available for '%s'"):format(cmd.platform), + { level = vim.log.levels.WARN } + ) + return + end + vim.ui.open(url) end elseif cmd.type == 'problem_jump' then local platform = state.get_platform() @@ -432,6 +467,9 @@ function M.handle_command(opts) local cache_commands = require('cp.commands.cache') cache_commands.handle_cache_command(cmd) elseif cmd.type == 'contest_setup' then + if not check_platform_enabled(cmd.platform) then + return + end local setup = require('cp.setup') setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language) return diff --git a/lua/cp/config.lua b/lua/cp/config.lua index 0593638..728b85f 100644 --- a/lua/cp/config.lua +++ b/lua/cp/config.lua @@ -107,6 +107,7 @@ ---@field runtime { effective: table> } -- computed ---@class cp.PartialConfig: cp.Config +---@field platforms? table local M = {} @@ -333,13 +334,18 @@ function M.setup(user_config) vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } }) local defaults = vim.deepcopy(M.defaults) if user_config and user_config.platforms then - for plat in pairs(defaults.platforms) do - if not user_config.platforms[plat] then + for plat, v in pairs(user_config.platforms) do + if v == false then defaults.platforms[plat] = nil end end end local cfg = vim.tbl_deep_extend('force', defaults, user_config or {}) + for plat, v in pairs(cfg.platforms) do + if v == false then + cfg.platforms[plat] = nil + end + end if not next(cfg.languages) then error('[cp.nvim] At least one language must be configured') From 4ef00afe6677454794449caf64d0a67efdcb5d9b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:14:33 -0500 Subject: [PATCH 099/114] feat(commands): add `:CP signup` subcommand (#337) ## Problem No quick way to reach a platform's registration page from within Neovim. ## Solution Add `signup` as a platform subcommand that calls `vim.ui.open` on the platform's registration URL. URLs live in a new `SIGNUP_URLS` table in `constants.lua`. Works even when the platform is disabled. Tab completion and vimdoc updated. --- lua/cp/constants.lua | 9 +++++++++ plugin/cp.lua | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/lua/cp/constants.lua b/lua/cp/constants.lua index b0dad5b..94c28ba 100644 --- a/lua/cp/constants.lua +++ b/lua/cp/constants.lua @@ -25,6 +25,15 @@ M.PLATFORM_DISPLAY_NAMES = { usaco = 'USACO', } +M.SIGNUP_URLS = { + atcoder = 'https://atcoder.jp/register', + codechef = 'https://www.codechef.com/register', + codeforces = 'https://codeforces.com/register', + cses = 'https://cses.fi/register', + kattis = 'https://open.kattis.com/register', + usaco = 'https://usaco.org/index.php?page=createaccount', +} + M.CPP = 'cpp' M.PYTHON = 'python' diff --git a/plugin/cp.lua b/plugin/cp.lua index 3e3b56c..2baa1e3 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -64,7 +64,7 @@ end, { return filter_candidates(candidates) elseif num_args == 3 then if vim.tbl_contains(platforms, args[2]) then - local candidates = { 'login', 'logout' } + local candidates = { 'login', 'logout', 'signup' } local cache = require('cp.cache') cache.load() vim.list_extend(candidates, cache.get_cached_contest_ids(args[2])) From 9727dccc6f19c47f129f40df936726cc06a58581 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:14:58 -0500 Subject: [PATCH 100/114] feat(commands): add tab completion for `:CP open` (#338) ## Problem `:CP open` accepts `problem`, `contest`, and `standings` but offered no tab completion for them. ## Solution Add an `args[2] == 'open'` branch in the `plugin/cp.lua` completer returning the three valid subcommands. --- plugin/cp.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin/cp.lua b/plugin/cp.lua index 2baa1e3..44b9d5c 100644 --- a/plugin/cp.lua +++ b/plugin/cp.lua @@ -112,6 +112,8 @@ end, { local candidates = { 'stop' } vim.list_extend(candidates, platforms) return filter_candidates(candidates) + elseif args[2] == 'open' then + return filter_candidates({ 'problem', 'contest', 'standings' }) elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then return filter_candidates({ '--lang' }) else From 8465e707725940c2fccf7dcdcbfe832da258003f Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:49:49 -0500 Subject: [PATCH 101/114] test: add offline fixture coverage for Kattis and USACO (#342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Kattis and USACO had zero offline test coverage — no fixtures, no conftest routers, and no entries in the test matrix. The `precision` field and error paths were also unverified across all platforms. ## Solution Add HTML fixtures for both platforms and wire up `httpx.AsyncClient.get` routers in `conftest.py` following the existing CSES/CodeChef pattern. Extend the test matrix from 12 to 23 parametrized cases. Add a dedicated test for the Kattis contest-vs-slug fallback path (verifying `contest_url` and `standings_url`), three parametrized metadata error cases, and a targeted assertion that `extract_precision` returns a non-`None` float for problems with floating-point tolerance hints. Closes #281. --- tests/conftest.py | 66 ++++++++++++++++++- .../kattis/contest_open2024_problems.html | 10 +++ tests/fixtures/kattis/contests.html | 10 +++ tests/fixtures/kattis/problem_hello.html | 11 ++++ tests/fixtures/kattis/problem_kth2024a.html | 17 +++++ tests/fixtures/kattis/problem_kth2024b.html | 12 ++++ tests/fixtures/usaco/contests.html | 3 + tests/fixtures/usaco/dec24results.html | 14 ++++ tests/fixtures/usaco/problem_1469.html | 10 +++ tests/fixtures/usaco/problem_1470.html | 7 ++ tests/fixtures/usaco/problem_1471.html | 7 ++ tests/test_scrapers.py | 50 ++++++++++++++ 12 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/kattis/contest_open2024_problems.html create mode 100644 tests/fixtures/kattis/contests.html create mode 100644 tests/fixtures/kattis/problem_hello.html create mode 100644 tests/fixtures/kattis/problem_kth2024a.html create mode 100644 tests/fixtures/kattis/problem_kth2024b.html create mode 100644 tests/fixtures/usaco/contests.html create mode 100644 tests/fixtures/usaco/dec24results.html create mode 100644 tests/fixtures/usaco/problem_1469.html create mode 100644 tests/fixtures/usaco/problem_1470.html create mode 100644 tests/fixtures/usaco/problem_1471.html diff --git a/tests/conftest.py b/tests/conftest.py index b6ff810..f1248a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,8 @@ from pathlib import Path from types import SimpleNamespace from typing import Any +import re + import httpx import pytest import requests @@ -103,6 +105,35 @@ def run_scraper_offline(fixture_text): raise AssertionError(f"No fixture for Codeforces url={url!r}") + def _router_kattis(*, url: str) -> str: + url = url.removeprefix("https://open.kattis.com") + if "/contests?" in url: + return fixture_text("kattis/contests.html") + m = re.search(r"/contests/([^/]+)/problems", url) + if m: + try: + return fixture_text(f"kattis/contest_{m.group(1)}_problems.html") + except FileNotFoundError: + return "" + if "/problems/" in url and "/file/statement" not in url: + slug = url.rstrip("/").split("/")[-1] + return fixture_text(f"kattis/problem_{slug}.html") + raise AssertionError(f"No fixture for Kattis url={url!r}") + + def _router_usaco(*, url: str) -> str: + if "page=contests" in url and "results" not in url: + return fixture_text("usaco/contests.html") + m = re.search(r"page=([a-z]+\d{2,4}results)", url) + if m: + try: + return fixture_text(f"usaco/{m.group(1)}.html") + except FileNotFoundError: + return "" + m = re.search(r"page=viewproblem2&cpid=(\d+)", url) + if m: + return fixture_text(f"usaco/problem_{m.group(1)}.html") + raise AssertionError(f"No fixture for USACO url={url!r}") + def _make_offline_fetches(scraper_name: str): match scraper_name: case "cses": @@ -213,6 +244,37 @@ def run_scraper_offline(fixture_text): "__offline_get_async": __offline_get_async, } + case "kattis": + + async def __offline_get_kattis(client, url: str, **kwargs): + if "/file/statement/samples.zip" in url: + raise httpx.HTTPError("not found") + html = _router_kattis(url=url) + return SimpleNamespace( + text=html, + content=html.encode(), + status_code=200, + raise_for_status=lambda: None, + ) + + return { + "__offline_get_async": __offline_get_kattis, + } + + case "usaco": + + async def __offline_get_usaco(client, url: str, **kwargs): + html = _router_usaco(url=url) + return SimpleNamespace( + text=html, + status_code=200, + raise_for_status=lambda: None, + ) + + return { + "__offline_get_async": __offline_get_usaco, + } + case _: raise AssertionError(f"Unknown scraper: {scraper_name}") @@ -221,6 +283,8 @@ def run_scraper_offline(fixture_text): "atcoder": "AtcoderScraper", "codeforces": "CodeforcesScraper", "codechef": "CodeChefScraper", + "kattis": "KattisScraper", + "usaco": "USACOScraper", } def _run(scraper_name: str, mode: str, *args: str): @@ -236,7 +300,7 @@ def run_scraper_offline(fixture_text): ns._get_async = offline_fetches["_get_async"] elif scraper_name == "cses": httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] - elif scraper_name == "codechef": + elif scraper_name in ("codechef", "kattis", "usaco"): httpx.AsyncClient.get = offline_fetches["__offline_get_async"] scraper_class = getattr(ns, scraper_classes[scraper_name]) diff --git a/tests/fixtures/kattis/contest_open2024_problems.html b/tests/fixtures/kattis/contest_open2024_problems.html new file mode 100644 index 0000000..4f6cda2 --- /dev/null +++ b/tests/fixtures/kattis/contest_open2024_problems.html @@ -0,0 +1,10 @@ + + + + + + + + + +
AArithmetic Sequence
BBinary Tree
diff --git a/tests/fixtures/kattis/contests.html b/tests/fixtures/kattis/contests.html new file mode 100644 index 0000000..5a318c6 --- /dev/null +++ b/tests/fixtures/kattis/contests.html @@ -0,0 +1,10 @@ + + + + + + + + + +
Open 20242024-03-30
ICPC 20232023-10-31
diff --git a/tests/fixtures/kattis/problem_hello.html b/tests/fixtures/kattis/problem_hello.html new file mode 100644 index 0000000..53daf52 --- /dev/null +++ b/tests/fixtures/kattis/problem_hello.html @@ -0,0 +1,11 @@ + +Hello World + +CPU Time limit1 second +Memory limit256 MB + +
Hello World
+
Hello World
+
+ + diff --git a/tests/fixtures/kattis/problem_kth2024a.html b/tests/fixtures/kattis/problem_kth2024a.html new file mode 100644 index 0000000..c36f5ca --- /dev/null +++ b/tests/fixtures/kattis/problem_kth2024a.html @@ -0,0 +1,17 @@ + +Arithmetic Sequence + +CPU Time limit2 seconds +Memory limit512 MB + +
3
+1 2 3
+
YES
+
+ +
2
+1 3
+
NO
+
+ + diff --git a/tests/fixtures/kattis/problem_kth2024b.html b/tests/fixtures/kattis/problem_kth2024b.html new file mode 100644 index 0000000..2701273 --- /dev/null +++ b/tests/fixtures/kattis/problem_kth2024b.html @@ -0,0 +1,12 @@ + +Binary Tree + +CPU Time limit1 second +Memory limit256 MB + +
5
+1 2 3 4 5
+
3
+
+ + diff --git a/tests/fixtures/usaco/contests.html b/tests/fixtures/usaco/contests.html new file mode 100644 index 0000000..2b90295 --- /dev/null +++ b/tests/fixtures/usaco/contests.html @@ -0,0 +1,3 @@ + +December 2024 Results + diff --git a/tests/fixtures/usaco/dec24results.html b/tests/fixtures/usaco/dec24results.html new file mode 100644 index 0000000..382d0f5 --- /dev/null +++ b/tests/fixtures/usaco/dec24results.html @@ -0,0 +1,14 @@ + + +

USACO 2024 December Contest, Gold

+ +Farmer John's Favorite Problem
+View Problem + +Binary Indexed Tree
+View Problem + +Counting Subsequences
+View Problem + + diff --git a/tests/fixtures/usaco/problem_1469.html b/tests/fixtures/usaco/problem_1469.html new file mode 100644 index 0000000..9553b98 --- /dev/null +++ b/tests/fixtures/usaco/problem_1469.html @@ -0,0 +1,10 @@ + +

Time limit: 4s. Memory limit: 256 MB.

+

Given N cows, find the answer.

+
3
+1 2 3
+
6
+
1
+5
+
5
+ diff --git a/tests/fixtures/usaco/problem_1470.html b/tests/fixtures/usaco/problem_1470.html new file mode 100644 index 0000000..bd88503 --- /dev/null +++ b/tests/fixtures/usaco/problem_1470.html @@ -0,0 +1,7 @@ + +

Time limit: 2s. Memory limit: 512 MB.

+

Build a binary indexed tree.

+
4
+1 3 2 4
+
10
+ diff --git a/tests/fixtures/usaco/problem_1471.html b/tests/fixtures/usaco/problem_1471.html new file mode 100644 index 0000000..0c6fdd0 --- /dev/null +++ b/tests/fixtures/usaco/problem_1471.html @@ -0,0 +1,7 @@ + +

Time limit: 4s. Memory limit: 256 MB.

+

Output the answer with absolute error at most 10^{-6}.

+
2
+1 2
+
1.500000
+ diff --git a/tests/test_scrapers.py b/tests/test_scrapers.py index 8ce468f..c4fa6d5 100644 --- a/tests/test_scrapers.py +++ b/tests/test_scrapers.py @@ -27,6 +27,16 @@ MATRIX = { "tests": ("START209D",), "contests": tuple(), }, + "kattis": { + "metadata": ("hello",), + "tests": ("hello",), + "contests": tuple(), + }, + "usaco": { + "metadata": ("dec24_gold",), + "tests": ("dec24_gold",), + "contests": tuple(), + }, } @@ -85,5 +95,45 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode): ) assert "multi_test" in obj, "Missing multi_test field in raw JSON" assert isinstance(obj["multi_test"], bool), "multi_test not boolean" + assert "precision" in obj, "Missing precision field in raw JSON" + assert obj["precision"] is None or isinstance( + obj["precision"], float + ), "precision must be None or float" validated_any = True assert validated_any, "No valid tests payloads validated" + + +def test_kattis_contest_metadata(run_scraper_offline): + rc, objs = run_scraper_offline("kattis", "metadata", "open2024") + assert rc == 0 + assert objs + model = MetadataResult.model_validate(objs[-1]) + assert model.success is True + assert len(model.problems) == 2 + assert model.contest_url != "" + assert model.standings_url != "" + + +def test_usaco_precision_extracted(run_scraper_offline): + rc, objs = run_scraper_offline("usaco", "tests", "dec24_gold") + assert rc == 0 + precisions = [obj["precision"] for obj in objs if "problem_id" in obj] + assert any(p is not None for p in precisions), ( + "Expected at least one problem with precision" + ) + + +@pytest.mark.parametrize( + "scraper,contest_id", + [ + ("cses", "nonexistent_category_xyz"), + ("usaco", "badformat"), + ("kattis", "nonexistent_problem_xyz"), + ], +) +def test_scraper_metadata_error(run_scraper_offline, scraper, contest_id): + rc, objs = run_scraper_offline(scraper, "metadata", contest_id) + assert rc == 1 + assert objs + assert objs[-1].get("success") is False + assert objs[-1].get("error") From 592f9772966839ae32999363bae02ee3e4e5b471 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:53:22 -0500 Subject: [PATCH 102/114] fix(login): remove cookie fast-path from login subcommand (#344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `:CP login` short-circuited on cached cookies/tokens — if an old session was still valid, the new credentials were never tested. The user got "login successful" even with wrong input. USACO submit also wasted a roundtrip on `_check_usaco_login` every time. ## Solution Always validate credentials in the login path. Remove cookie/token loading from `_login_headless` (AtCoder), `_login_headless_cf` (CF), `_login_headless_codechef` (CodeChef), and `login` (CSES). For USACO submit, replace the `_check_usaco_login` roundtrip with cookie trust + retry-on-auth-failure (the Kattis pattern). Submit paths are unchanged — cookie fast-paths remain for contest speed. Closes #331 --- scrapers/atcoder.py | 42 ++++++----------- scrapers/codechef.py | 32 +++---------- scrapers/codeforces.py | 48 +++++++------------- scrapers/cses.py | 15 ------- scrapers/usaco.py | 100 +++++++++++++++++++++++++---------------- 5 files changed, 97 insertions(+), 140 deletions(-) diff --git a/scrapers/atcoder.py b/scrapers/atcoder.py index b25ea7e..c31a6ee 100644 --- a/scrapers/atcoder.py +++ b/scrapers/atcoder.py @@ -306,12 +306,6 @@ def _login_headless(credentials: dict[str, str]) -> LoginResult: cookie_cache = Path.home() / ".cache" / "cp-nvim" / "atcoder-cookies.json" cookie_cache.parent.mkdir(parents=True, exist_ok=True) - saved_cookies: list[dict[str, Any]] = [] - if cookie_cache.exists(): - try: - saved_cookies = json.loads(cookie_cache.read_text()) - except Exception: - pass logged_in = False login_error: str | None = None @@ -340,33 +334,23 @@ def _login_headless(credentials: dict[str, str]) -> LoginResult: headless=True, timeout=BROWSER_SESSION_TIMEOUT, google_search=False, - cookies=saved_cookies if saved_cookies else [], ) as session: - if saved_cookies: - print(json.dumps({"status": "checking_login"}), flush=True) - session.fetch( - f"{BASE_URL}/home", page_action=check_login, network_idle=True - ) + print(json.dumps({"status": "logging_in"}), flush=True) + session.fetch( + f"{BASE_URL}/login", + page_action=login_action, + solve_cloudflare=True, + ) + if login_error: + return LoginResult(success=False, error=f"Login failed: {login_error}") + session.fetch( + f"{BASE_URL}/home", page_action=check_login, network_idle=True + ) if not logged_in: - print(json.dumps({"status": "logging_in"}), flush=True) - session.fetch( - f"{BASE_URL}/login", - page_action=login_action, - solve_cloudflare=True, + return LoginResult( + success=False, error="Login failed (bad credentials?)" ) - if login_error: - return LoginResult( - success=False, error=f"Login failed: {login_error}" - ) - - session.fetch( - f"{BASE_URL}/home", page_action=check_login, network_idle=True - ) - if not logged_in: - return LoginResult( - success=False, error="Login failed (bad credentials?)" - ) try: browser_cookies = session.context.cookies() diff --git a/scrapers/codechef.py b/scrapers/codechef.py index 8f13eee..614e8c2 100644 --- a/scrapers/codechef.py +++ b/scrapers/codechef.py @@ -65,12 +65,6 @@ def _login_headless_codechef(credentials: dict[str, str]) -> LoginResult: _ensure_browser() _COOKIE_PATH.parent.mkdir(parents=True, exist_ok=True) - saved_cookies: list[dict[str, Any]] = [] - if _COOKIE_PATH.exists(): - try: - saved_cookies = json.loads(_COOKIE_PATH.read_text()) - except Exception: - pass logged_in = False login_error: str | None = None @@ -100,29 +94,17 @@ def _login_headless_codechef(credentials: dict[str, str]) -> LoginResult: headless=True, timeout=BROWSER_SESSION_TIMEOUT, google_search=False, - cookies=saved_cookies if saved_cookies else [], ) as session: - if saved_cookies: - print(json.dumps({"status": "checking_login"}), flush=True) - session.fetch( - f"{BASE_URL}/", page_action=check_login, network_idle=True - ) + print(json.dumps({"status": "logging_in"}), flush=True) + session.fetch(f"{BASE_URL}/login", page_action=login_action) + if login_error: + return LoginResult(success=False, error=f"Login failed: {login_error}") + session.fetch(f"{BASE_URL}/", page_action=check_login, network_idle=True) if not logged_in: - print(json.dumps({"status": "logging_in"}), flush=True) - session.fetch(f"{BASE_URL}/login", page_action=login_action) - if login_error: - return LoginResult( - success=False, error=f"Login failed: {login_error}" - ) - - session.fetch( - f"{BASE_URL}/", page_action=check_login, network_idle=True + return LoginResult( + success=False, error="Login failed (bad credentials?)" ) - if not logged_in: - return LoginResult( - success=False, error="Login failed (bad credentials?)" - ) try: browser_cookies = session.context.cookies() diff --git a/scrapers/codeforces.py b/scrapers/codeforces.py index 8ae768e..d5b6161 100644 --- a/scrapers/codeforces.py +++ b/scrapers/codeforces.py @@ -348,12 +348,6 @@ def _login_headless_cf(credentials: dict[str, str]) -> LoginResult: cookie_cache = Path.home() / ".cache" / "cp-nvim" / "codeforces-cookies.json" cookie_cache.parent.mkdir(parents=True, exist_ok=True) - saved_cookies: list[dict[str, Any]] = [] - if cookie_cache.exists(): - try: - saved_cookies = json.loads(cookie_cache.read_text()) - except Exception: - pass logged_in = False login_error: str | None = None @@ -388,37 +382,25 @@ def _login_headless_cf(credentials: dict[str, str]) -> LoginResult: headless=True, timeout=BROWSER_SESSION_TIMEOUT, google_search=False, - cookies=saved_cookies if saved_cookies else [], ) as session: - if saved_cookies: - print(json.dumps({"status": "checking_login"}), flush=True) - session.fetch( - f"{BASE_URL}/", - page_action=check_login, - network_idle=True, - ) + print(json.dumps({"status": "logging_in"}), flush=True) + session.fetch( + f"{BASE_URL}/enter", + page_action=login_action, + solve_cloudflare=True, + ) + if login_error: + return LoginResult(success=False, error=f"Login failed: {login_error}") + session.fetch( + f"{BASE_URL}/", + page_action=check_login, + network_idle=True, + ) if not logged_in: - print(json.dumps({"status": "logging_in"}), flush=True) - session.fetch( - f"{BASE_URL}/enter", - page_action=login_action, - solve_cloudflare=True, + return LoginResult( + success=False, error="Login failed (bad credentials?)" ) - if login_error: - return LoginResult( - success=False, error=f"Login failed: {login_error}" - ) - - session.fetch( - f"{BASE_URL}/", - page_action=check_login, - network_idle=True, - ) - if not logged_in: - return LoginResult( - success=False, error="Login failed (bad credentials?)" - ) try: browser_cookies = session.context.cookies() diff --git a/scrapers/cses.py b/scrapers/cses.py index bd29af4..bf1edbd 100644 --- a/scrapers/cses.py +++ b/scrapers/cses.py @@ -239,21 +239,6 @@ class CSESScraper(BaseScraper): return self._login_error("Missing username or password") async with httpx.AsyncClient(follow_redirects=True) as client: - token = credentials.get("token") - - if token: - print(json.dumps({"status": "checking_login"}), flush=True) - if await self._check_token(client, token): - return LoginResult( - success=True, - error="", - credentials={ - "username": username, - "password": password, - "token": token, - }, - ) - print(json.dumps({"status": "logging_in"}), flush=True) token = await self._web_login(client, username, password) if not token: diff --git a/scrapers/usaco.py b/scrapers/usaco.py index 5ab89f7..53f92be 100644 --- a/scrapers/usaco.py +++ b/scrapers/usaco.py @@ -423,11 +423,7 @@ class USACOScraper(BaseScraper): async with httpx.AsyncClient(follow_redirects=True) as client: await _load_usaco_cookies(client) - print(json.dumps({"status": "checking_login"}), flush=True) - logged_in = bool(client.cookies) and await _check_usaco_login( - client, username - ) - if not logged_in: + if not client.cookies: print(json.dumps({"status": "logging_in"}), flush=True) try: ok = await _do_usaco_login(client, username, password) @@ -437,44 +433,72 @@ class USACOScraper(BaseScraper): return self._submit_error("Login failed (bad credentials?)") await _save_usaco_cookies(client) - print(json.dumps({"status": "submitting"}), flush=True) - try: - page_r = await client.get( - f"{_AUTH_BASE}/index.php?page=viewproblem2&cpid={problem_id}", - headers=HEADERS, - timeout=HTTP_TIMEOUT, - ) - form_url, hidden_fields, lang_val = _parse_submit_form( - page_r.text, language_id - ) - except Exception: - form_url = _AUTH_BASE + _SUBMIT_PATH - hidden_fields = {} - lang_val = None + result = await self._do_submit(client, problem_id, language_id, source) - data: dict[str, str] = {"cpid": problem_id, **hidden_fields} - data["language"] = lang_val if lang_val is not None else language_id - ext = "py" if "python" in language_id.lower() else "cpp" + if result.success or result.error != "auth_failure": + return result + + client.cookies.clear() + print(json.dumps({"status": "logging_in"}), flush=True) try: - r = await client.post( - form_url, - data=data, - files={"sourcefile": (f"solution.{ext}", source, "text/plain")}, - headers=HEADERS, - timeout=HTTP_TIMEOUT, - ) - r.raise_for_status() + ok = await _do_usaco_login(client, username, password) except Exception as e: - return self._submit_error(f"Submit request failed: {e}") + return self._submit_error(f"Login failed: {e}") + if not ok: + return self._submit_error("Login failed (bad credentials?)") + await _save_usaco_cookies(client) - try: - resp = r.json() - sid = str(resp.get("submission_id", resp.get("id", ""))) - except Exception: - sid = "" - return SubmitResult( - success=True, error="", submission_id=sid, verdict="submitted" + return await self._do_submit(client, problem_id, language_id, source) + + async def _do_submit( + self, + client: httpx.AsyncClient, + problem_id: str, + language_id: str, + source: bytes, + ) -> SubmitResult: + print(json.dumps({"status": "submitting"}), flush=True) + try: + page_r = await client.get( + f"{_AUTH_BASE}/index.php?page=viewproblem2&cpid={problem_id}", + headers=HEADERS, + timeout=HTTP_TIMEOUT, ) + if "login" in page_r.url.path.lower() or "Login" in page_r.text[:2000]: + return self._submit_error("auth_failure") + form_url, hidden_fields, lang_val = _parse_submit_form( + page_r.text, language_id + ) + except Exception: + form_url = _AUTH_BASE + _SUBMIT_PATH + hidden_fields = {} + lang_val = None + + data: dict[str, str] = {"cpid": problem_id, **hidden_fields} + data["language"] = lang_val if lang_val is not None else language_id + ext = "py" if "python" in language_id.lower() else "cpp" + try: + r = await client.post( + form_url, + data=data, + files={"sourcefile": (f"solution.{ext}", source, "text/plain")}, + headers=HEADERS, + timeout=HTTP_TIMEOUT, + ) + r.raise_for_status() + except Exception as e: + return self._submit_error(f"Submit request failed: {e}") + + try: + resp = r.json() + if resp.get("code") == 0 and "login" in resp.get("message", "").lower(): + return self._submit_error("auth_failure") + sid = str(resp.get("submission_id", resp.get("id", ""))) + except Exception: + sid = "" + return SubmitResult( + success=True, error="", submission_id=sid, verdict="submitted" + ) async def login(self, credentials: dict[str, str]) -> LoginResult: username = credentials.get("username", "") From 58b68408156400f3cd6589ec7088203a0e771f4b Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:18:21 -0500 Subject: [PATCH 103/114] feat: race countdown support and language version selection (#346) ## Problem Two gaps in the plugin: (1) contest pickers have no way to know whether a contest supports countdown (race), so the UI can't surface that affordance; (2) `:CP submit` hardcodes a single language ID per platform with no way to choose C++ standard or override the platform ID. ## Solution **Race countdown** (`4e709c8`): Add `supports_countdown` boolean to `ContestListResult` and wire it through CSES/USACO scrapers, cache, race module, and pickers. **Language version selection** (`b90ac67`): Add `LANGUAGE_VERSIONS` and `DEFAULT_VERSIONS` tables in `constants.lua`. Config gains `version` and `submit_id` fields validated at setup time. `submit.lua` resolves the effective config to a platform ID (priority: `submit_id` > `version` > default). Helpdocs add `*cp-submit-language*` section. Tests cover `LANGUAGE_IDS` completeness. --- doc/cp.nvim.txt | 72 +++++++++++++++++++++++++++++++++++------ lua/cp/cache.lua | 26 ++++++++++++++- lua/cp/config.lua | 27 ++++++++++++++++ lua/cp/constants.lua | 17 ++++++++++ lua/cp/pickers/init.lua | 6 ++-- lua/cp/race.lua | 65 ++++++++++++++++++++++++------------- lua/cp/scraper.lua | 7 ++-- lua/cp/submit.lua | 22 ++++++++++++- plugin/cp.lua | 22 +++++++++++-- scrapers/cses.py | 8 +++-- scrapers/models.py | 1 + scrapers/usaco.py | 12 +++++-- tests/test_scrapers.py | 26 +++++++++++++++ 13 files changed, 265 insertions(+), 46 deletions(-) diff --git a/doc/cp.nvim.txt b/doc/cp.nvim.txt index 1db397b..add0bcb 100644 --- a/doc/cp.nvim.txt +++ b/doc/cp.nvim.txt @@ -24,15 +24,16 @@ CONTENTS *cp-contents* 16. Race .......................................................... |cp-race| 17. Credentials ............................................ |cp-credentials| 18. Submit ...................................................... |cp-submit| - 19. Open ......................................................... |cp-open| - 20. ANSI Colors ................................................... |cp-ansi| - 21. Highlight Groups ........................................ |cp-highlights| - 22. Terminal Colors .................................... |cp-terminal-colors| - 23. Highlight Customization .......................... |cp-highlight-custom| - 24. Helpers .................................................... |cp-helpers| - 25. Statusline Integration .................................. |cp-statusline| - 26. Panel Keymaps .......................................... |cp-panel-keys| - 27. File Structure ................................................ |cp-files| + 19. Submit Language Versions ......................... |cp-submit-language| + 20. Open ......................................................... |cp-open| + 21. ANSI Colors ................................................... |cp-ansi| + 22. Highlight Groups ........................................ |cp-highlights| + 23. Terminal Colors .................................... |cp-terminal-colors| + 24. Highlight Customization .......................... |cp-highlight-custom| + 25. Helpers .................................................... |cp-helpers| + 26. Statusline Integration .................................. |cp-statusline| + 27. Panel Keymaps .......................................... |cp-panel-keys| + 28. File Structure ................................................ |cp-files| 28. Health Check ................................................ |cp-health| ============================================================================== @@ -1032,7 +1033,58 @@ Submit the current solution to the online judge. Platform support: AtCoder Fully implemented. - Others Not yet implemented. + Codeforces Fully implemented. + CSES Fully implemented. + Kattis Fully implemented. + USACO Fully implemented. + CodeChef Not yet implemented. + + See |cp-submit-language| for configuring the language version + (e.g. C++20 instead of C++17). + +============================================================================== +SUBMIT LANGUAGE VERSIONS *cp-submit-language* + +When submitting, cp.nvim selects a language version for the platform. +The default is C++17 for cpp and Python 3 for python. + +Configuring a version ~ + +Set {version} globally or per-platform: +>lua + languages = { + cpp = { version = "c++20", ... }, + }, + platforms = { + atcoder = { + overrides = { cpp = { version = "c++23" } }, + }, + }, +< +Available versions per platform ~ + + Platform cpp python + AtCoder c++23 python3 + Codeforces c++17 python3 + CSES c++17 python3 + Kattis c++17/20/23 python3 + USACO c++17/20/23 python3 + CodeChef c++17 python3 + +Using a raw platform ID ~ + +If your preferred version is not listed, you can bypass version +lookup by setting {submit_id} to the raw platform language ID: +>lua + platforms = { + codeforces = { + overrides = { cpp = { submit_id = "91" } }, + }, + }, +< +To find the raw ID, open the platform's submit page in your browser, +inspect the language dropdown, and copy the