Compare commits

...
Sign in to create a new pull request.

26 commits

Author SHA1 Message Date
6abe02f5ee
fix(ci): format 2026-02-20 17:47:36 -05:00
c4d410d290 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.
2026-02-18 17:48:06 -05:00
622620f6d0 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.
2026-02-18 17:40:06 -05:00
976838d981 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.
2026-02-18 17:32:12 -05:00
06f72bbe2b 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.
2026-02-18 17:29:41 -05:00
6045042dfb 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.
2026-02-18 17:25:50 -05:00
c192afc5d7 fix(ci): format 2026-02-18 14:13:37 -05:00
b6f3398bbc fix(ci): formatting and typing 2026-02-18 14:13:37 -05:00
e02a29bd40 fix(ci): remove duplicate workflows 2026-02-18 14:13:37 -05:00
0f9715298e fix(ci): remove deprecated setups 2026-02-18 14:13:37 -05:00
2148d9bd07 feat(nix): add health 2026-02-18 14:13:37 -05:00
1162e7046b try to fix the setup 2026-02-18 14:13:37 -05:00
b36ffba63a feat(nix): initial flake config; 2026-02-18 14:13:37 -05:00
04d0c124cf
fix: remove flake config 2026-02-17 21:11:11 -05:00
da433068ef
remove straggler file 2026-02-17 21:10:56 -05:00
51504b0121
fix: flake config; 2026-02-17 21:10:29 -05:00
49df7e015d 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.
2026-02-17 21:09:58 -05:00
029ea125b9 feat: add <Plug> 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 <Plug> 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.
2026-02-07 13:23:45 -05:00
Barrett Ruth
43193c3762
Merge pull request #239 from barrettruth/refactor/remove-cp-config-compat
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
refactor: remove vim.g.cp_config compatibility shim
2026-02-06 16:42:41 -05:00
de2bc07532 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.
2026-02-06 16:40:39 -05:00
Barrett Ruth
041e09ac04
Merge pull request #238 from barrettruth/fix/setup-code-hook-language
fix(setup): set language state before setup_code hook on first open
2026-02-06 16:38:41 -05:00
d23b4e59d1 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.
2026-02-06 16:29:46 -05:00
Barrett Ruth
19e71ac7fa
Merge pull request #237 from barrettruth/feat/vim-g-update
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
refactor: rename `vim.g.cp_config` to `vim.g.cp`
2026-02-06 16:07:03 -05:00
a54a06f939 refactor: rename vim.g.cp_config to vim.g.cp 2026-02-06 15:16:21 -05:00
Barrett Ruth
b2c7f16890
Merge pull request #234 from barrettruth/fix/deprecation-warning
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
fix: add deprecation warning for setup()
2026-02-03 21:51:19 -05:00
276241447c fix: add deprecation warning for setup()
Some checks are pending
luarocks / ci (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
2026-02-03 21:46:47 -05:00
23 changed files with 674 additions and 1727 deletions

3
.envrc
View file

@ -1,3 +0,0 @@
VIRTUAL_ENV="$PWD/.venv"
PATH_add "$VIRTUAL_ENV/bin"
export VIRTUAL_ENV

View file

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

View file

@ -44,9 +44,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v4 uses: astral-sh/setup-uv@v4
- name: Install dependencies with pytest - name: Install dependencies
run: uv sync --dev run: uv sync --dev
- name: Fetch camoufox data
run: uv run camoufox fetch
- name: Run Python tests - name: Run Python tests
run: uv run pytest tests/ -v run: uv run pytest tests/ -v

3
.gitignore vendored
View file

@ -14,3 +14,6 @@ __pycache__
.claude/ .claude/
node_modules/ node_modules/
.envrc
.direnv/

View file

@ -18,6 +18,243 @@ REQUIREMENTS *cp-requirements*
- Unix-like operating system - Unix-like operating system
- uv package manager (https://docs.astral.sh/uv/) - uv package manager (https://docs.astral.sh/uv/)
==============================================================================
SETUP *cp-setup*
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*
Configuration is done via `vim.g.cp`. Set this before using the plugin:
>lua
vim.g.cp = {
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}',
'-fdiagnostics-color=always' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
},
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
cpp = { extension = 'cpp', commands = { build = { ... } } }
},
},
atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
open_url = true,
debug = false,
ui = {
ansi = true,
run = {
width = 0.3,
next_test_key = '<c-n>', -- or nil to disable
prev_test_key = '<c-p>', -- or nil to disable
},
panel = {
diff_modes = { 'side-by-side', 'git', 'vim' },
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' },
},
},
picker = 'telescope',
},
}
<
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'.
For example, to run CodeForces contests with Python by default:
>lua
vim.g.cp = {
platforms = {
codeforces = {
default_language = 'python',
},
},
}
<
Any language is supported provided the proper configuration. For example, to
run CSES problems with Rust using the single schema:
>lua
vim.g.cp = {
languages = {
rust = {
extension = 'rs',
commands = {
build = { 'rustc', '{source}', '-o', '{binary}' },
run = { '{binary}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python', 'rust' },
default_language = 'rust',
},
},
}
<
*cp.Config*
Fields: ~
{languages} (table<string,|CpLanguage|>) Global language registry.
Each language provides an {extension} and {commands}.
{platforms} (table<string,|CpPlatform|>) Per-platform enablement,
default language, and optional overrides.
{hooks} (|cp.Hooks|) Hook functions called at various stages.
{debug} (boolean, default: false) Show info messages.
{scrapers} (string[]) Supported platform ids.
{filename} (function, optional)
function(contest, contest_id, problem_id, config, language): string
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 the contest is first opened.
*CpPlatform*
Fields: ~
{enabled_languages} (string[]) Language ids enabled on this platform.
{default_language} (string) One of {enabled_languages}.
{overrides} (table<string,|CpPlatformOverrides|>, optional)
Per-language overrides of {extension} and/or {commands}.
*CpLanguage*
Fields: ~
{extension} (string) File extension without leading dot.
{commands} (|CpLangCommands|) Command templates.
*CpLangCommands*
Fields: ~
{build} (string[], optional) For compiled languages.
Must include {source} and {binary}.
{run} (string[], optional) Runtime command.
Compiled: must include {binary}.
Interpreted: must include {source}.
{debug} (string[], optional) Debug variant; same token rules
as {build} (compiled) or {run} (interpreted).
*CpUI*
Fields: ~
{ansi} (boolean, default: true) Enable ANSI color parsing
and highlighting in both I/O view and panel.
{run} (|RunConfig|) I/O view configuration.
{panel} (|PanelConfig|) Test panel behavior configuration.
{diff} (|DiffConfig|) Diff backend configuration.
{picker} (string|nil) 'telescope', 'fzf-lua', or nil.
*RunConfig*
Fields: ~
{width} (number, default: 0.3) Width of I/O view splits as
fraction of screen (0.0 to 1.0).
{next_test_key} (string|nil, default: '<c-n>') Keymap to navigate
to next test in I/O view. Set to nil to disable.
{prev_test_key} (string|nil, default: '<c-p>') Keymap to navigate
to previous test in I/O view. Set to nil to disable.
{format_verdict} (|VerdictFormatter|, default: nil) Custom verdict line
formatter. See |cp-verdict-format|.
*EditConfig*
Fields: ~
{next_test_key} (string|nil, default: ']t') Jump to next test.
{prev_test_key} (string|nil, default: '[t') Jump to previous test.
{delete_test_key} (string|nil, default: 'gd') Delete current test.
{add_test_key} (string|nil, default: 'ga') Add new test.
{save_and_exit_key} (string|nil, default: 'q') Save and exit editor.
All keys are nil-able. Set to nil to disable.
*cp.PanelConfig*
Fields: ~
{diff_modes} (string[], default: {'side-by-side', 'git', 'vim'})
List of diff modes to cycle through with 't' key.
First element is the default mode.
Valid modes: 'side-by-side', 'git', 'vim'.
{max_output_lines} (number, default: 50) Maximum lines of test output.
*cp.DiffConfig*
Fields: ~
{git} (|cp.DiffGitConfig|) Git diff backend configuration.
*cp.DiffGitConfig*
Fields: ~
{args} (string[]) Command-line arguments for git diff.
Default: { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' }
• --no-index: Compare files outside git repository
• --word-diff=plain: Character-level diff markers
• --word-diff-regex=.: Split on every character
• --no-prefix: Remove a/ b/ prefixes from output
*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)
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
}
<
============================================================================== ==============================================================================
COMMANDS *cp-commands* COMMANDS *cp-commands*
@ -203,232 +440,40 @@ Debug Builds ~
< <
============================================================================== ==============================================================================
CONFIGURATION *cp-config* MAPPINGS *cp-mappings*
Configuration is done via `vim.g.cp_config`. Set this before using the plugin: cp.nvim provides <Plug> mappings for all primary actions. These dispatch
>lua through the same code path as |:CP|.
vim.g.cp_config = {
languages = {
cpp = {
extension = 'cc',
commands = {
build = { 'g++', '-std=c++17', '{source}', '-o', '{binary}',
'-fdiagnostics-color=always' },
run = { '{binary}' },
debug = { 'g++', '-std=c++17', '-fsanitize=address,undefined',
'{source}', '-o', '{binary}' },
},
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
cpp = { extension = 'cpp', commands = { build = { ... } } }
},
},
atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
open_url = true,
debug = false,
ui = {
ansi = true,
run = {
width = 0.3,
next_test_key = '<c-n>', -- or nil to disable
prev_test_key = '<c-p>', -- or nil to disable
},
panel = {
diff_modes = { 'side-by-side', 'git', 'vim' },
max_output_lines = 50,
},
diff = {
git = {
args = { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' },
},
},
picker = 'telescope',
},
}
<
By default, C++ (g++ with ISO C++17) and Python are preconfigured under *<Plug>(cp-run)*
'languages'. Platforms select which languages are enabled and which one is <Plug>(cp-run) Run tests in I/O view. Equivalent to :CP run.
the default; per-platform overrides can tweak 'extension' or 'commands'.
For example, to run CodeForces contests with Python by default: *<Plug>(cp-panel)*
>lua <Plug>(cp-panel) Open full-screen test panel. Equivalent to :CP panel.
vim.g.cp_config = {
platforms = {
codeforces = {
default_language = 'python',
},
},
}
<
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 = {
languages = {
rust = {
extension = 'rs',
commands = {
build = { 'rustc', '{source}', '-o', '{binary}' },
run = { '{binary}' },
},
},
},
platforms = {
cses = {
enabled_languages = { 'cpp', 'python', 'rust' },
default_language = 'rust',
},
},
}
<
*cp.Config*
Fields: ~
{languages} (table<string,|CpLanguage|>) Global language registry.
Each language provides an {extension} and {commands}.
{platforms} (table<string,|CpPlatform|>) Per-platform enablement,
default language, and optional overrides.
{hooks} (|cp.Hooks|) Hook functions called at various stages.
{debug} (boolean, default: false) Show info messages.
{scrapers} (string[]) Supported platform ids.
{filename} (function, optional)
function(contest, contest_id, problem_id, config, language): string
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 the contest is first opened.
*CpPlatform* *<Plug>(cp-edit)*
Fields: ~ <Plug>(cp-edit) Open the test case editor. Equivalent to :CP edit.
{enabled_languages} (string[]) Language ids enabled on this platform.
{default_language} (string) One of {enabled_languages}.
{overrides} (table<string,|CpPlatformOverrides|>, optional)
Per-language overrides of {extension} and/or {commands}.
*CpLanguage* *<Plug>(cp-next)*
Fields: ~ <Plug>(cp-next) Navigate to the next problem. Equivalent to :CP next.
{extension} (string) File extension without leading dot.
{commands} (|CpLangCommands|) Command templates.
*CpLangCommands* *<Plug>(cp-prev)*
Fields: ~ <Plug>(cp-prev) Navigate to the previous problem. Equivalent to :CP prev.
{build} (string[], optional) For compiled languages.
Must include {source} and {binary}.
{run} (string[], optional) Runtime command.
Compiled: must include {binary}.
Interpreted: must include {source}.
{debug} (string[], optional) Debug variant; same token rules
as {build} (compiled) or {run} (interpreted).
*CpUI* *<Plug>(cp-pick)*
Fields: ~ <Plug>(cp-pick) Launch the contest picker. Equivalent to :CP pick.
{ansi} (boolean, default: true) Enable ANSI color parsing
and highlighting in both I/O view and panel.
{run} (|RunConfig|) I/O view configuration.
{panel} (|PanelConfig|) Test panel behavior configuration.
{diff} (|DiffConfig|) Diff backend configuration.
{picker} (string|nil) 'telescope', 'fzf-lua', or nil.
*RunConfig* *<Plug>(cp-interact)*
Fields: ~ <Plug>(cp-interact) Open interactive mode. Equivalent to :CP interact.
{width} (number, default: 0.3) Width of I/O view splits as
fraction of screen (0.0 to 1.0).
{next_test_key} (string|nil, default: '<c-n>') Keymap to navigate
to next test in I/O view. Set to nil to disable.
{prev_test_key} (string|nil, default: '<c-p>') Keymap to navigate
to previous test in I/O view. Set to nil to disable.
{format_verdict} (|VerdictFormatter|, default: nil) Custom verdict line
formatter. See |cp-verdict-format|.
*EditConfig* Example configuration: >lua
Fields: ~ vim.keymap.set('n', '<leader>cr', '<Plug>(cp-run)')
{next_test_key} (string|nil, default: ']t') Jump to next test. vim.keymap.set('n', '<leader>cp', '<Plug>(cp-panel)')
{prev_test_key} (string|nil, default: '[t') Jump to previous test. vim.keymap.set('n', '<leader>ce', '<Plug>(cp-edit)')
{delete_test_key} (string|nil, default: 'gd') Delete current test. vim.keymap.set('n', '<leader>cn', '<Plug>(cp-next)')
{add_test_key} (string|nil, default: 'ga') Add new test. vim.keymap.set('n', '<leader>cN', '<Plug>(cp-prev)')
{save_and_exit_key} (string|nil, default: 'q') Save and exit editor. vim.keymap.set('n', '<leader>cc', '<Plug>(cp-pick)')
All keys are nil-able. Set to nil to disable. vim.keymap.set('n', '<leader>ci', '<Plug>(cp-interact)')
*cp.PanelConfig*
Fields: ~
{diff_modes} (string[], default: {'side-by-side', 'git', 'vim'})
List of diff modes to cycle through with 't' key.
First element is the default mode.
Valid modes: 'side-by-side', 'git', 'vim'.
{max_output_lines} (number, default: 50) Maximum lines of test output.
*cp.DiffConfig*
Fields: ~
{git} (|cp.DiffGitConfig|) Git diff backend configuration.
*cp.DiffGitConfig*
Fields: ~
{args} (string[]) Command-line arguments for git diff.
Default: { 'diff', '--no-index', '--word-diff=plain',
'--word-diff-regex=.', '--no-prefix' }
• --no-index: Compare files outside git repository
• --word-diff=plain: Character-level diff markers
• --word-diff-regex=.: Split on every character
• --no-prefix: Remove a/ b/ prefixes from output
*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)
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)
-- Custom setup for input buffer
vim.api.nvim_set_option_value('number', false, { buf = bufnr })
end
}
< <
============================================================================== ==============================================================================

43
flake.lock generated Normal file
View file

@ -0,0 +1,43 @@
{
"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",
"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"
}
}
},
"root": "root",
"version": 7
}

72
flake.nix Normal file
View file

@ -0,0 +1,72 @@
{
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
];
};
});
};
}

View file

@ -292,7 +292,15 @@ end
---@return cp.Config ---@return cp.Config
function M.setup(user_config) function M.setup(user_config)
vim.validate({ user_config = { user_config, { 'table', 'nil' }, true } }) 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 if not next(cfg.languages) then
error('[cp.nvim] At least one language must be configured') error('[cp.nvim] At least one language must be configured')

View file

@ -5,6 +5,8 @@ local utils = require('cp.utils')
local function check() local function check()
vim.health.start('cp.nvim [required] ~') vim.health.start('cp.nvim [required] ~')
utils.setup_python_env()
if vim.fn.has('nvim-0.10.0') == 1 then if vim.fn.has('nvim-0.10.0') == 1 then
vim.health.ok('Neovim 0.10.0+ detected') vim.health.ok('Neovim 0.10.0+ detected')
else else
@ -16,6 +18,16 @@ local function check()
vim.health.error('Windows is not supported') vim.health.error('Windows is not supported')
end end
if utils.is_nix_build() then
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 version: ' .. r.stdout:gsub('\n', ''))
end
else
if vim.fn.executable('uv') == 1 then if vim.fn.executable('uv') == 1 then
vim.health.ok('uv executable found') vim.health.ok('uv executable found')
local r = vim.system({ 'uv', '--version' }, { text = true }):wait() local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
@ -26,6 +38,10 @@ local function check()
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)') vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end 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 plugin_path = utils.get_plugin_path()
local venv_dir = plugin_path .. '/.venv' local venv_dir = plugin_path .. '/.venv'
if vim.fn.isdirectory(venv_dir) == 1 then if vim.fn.isdirectory(venv_dir) == 1 then
@ -33,6 +49,7 @@ local function check()
else else
vim.health.info('Python virtual environment not set up (created on first scrape)') vim.health.info('Python virtual environment not set up (created on first scrape)')
end end
end
local time_cap = utils.time_capability() local time_cap = utils.time_capability()
if time_cap.ok then if time_cap.ok then
@ -41,7 +58,7 @@ local function check()
vim.health.error('GNU time not found: ' .. (time_cap.reason or '')) vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
end end
local timeout_cap = utils.time_capability() local timeout_cap = utils.timeout_capability()
if timeout_cap.ok then if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path) vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else else

View file

@ -15,17 +15,25 @@ local initialized = false
local function ensure_initialized() local function ensure_initialized()
if initialized then if initialized then
return return true
end end
local user_config = vim.g.cp_config or {} local user_config = vim.g.cp or {}
local config = config_module.setup(user_config) local ok, result = pcall(config_module.setup, user_config)
config_module.set_current_config(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 initialized = true
return true
end end
---@return nil ---@return nil
function M.handle_command(opts) function M.handle_command(opts)
ensure_initialized() if not ensure_initialized() then
return
end
local commands = require('cp.commands') local commands = require('cp.commands')
commands.handle_command(opts) commands.handle_command(opts)
end end
@ -34,4 +42,13 @@ function M.is_initialized()
return initialized return initialized
end end
---@deprecated Use `vim.g.cp` instead
function M.setup(user_config)
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)
end
end
return M return M

View file

@ -43,6 +43,7 @@ end
function M.compile(compile_cmd, substitutions, on_complete) function M.compile(compile_cmd, substitutions, on_complete)
local cmd = substitute_template(compile_cmd, substitutions) local cmd = substitute_template(compile_cmd, substitutions)
local sh = table.concat(cmd, ' ') .. ' 2>&1' local sh = table.concat(cmd, ' ') .. ' 2>&1'
logger.log('compile: ' .. sh)
local t0 = vim.uv.hrtime() local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { text = false }, function(r) 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 sec = math.ceil(timeout_ms / 1000)
local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec) 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) 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() local t0 = vim.uv.hrtime()
vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r) vim.system({ 'sh', '-c', sh }, { stdin = stdin, text = true }, function(r)

View file

@ -25,10 +25,22 @@ end
---@param args 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) }
local function run_scraper(platform, subcommand, args, opts) 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 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) vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ() local env = vim.fn.environ()
env.VIRTUAL_ENV = '' env.VIRTUAL_ENV = ''
env.PYTHONPATH = '' env.PYTHONPATH = ''
@ -41,10 +53,12 @@ local function run_scraper(platform, subcommand, args, opts)
local buf = '' local buf = ''
local handle local handle
handle = uv.spawn( handle = uv.spawn(cmd[1], {
cmd[1], args = vim.list_slice(cmd, 2),
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env }, stdio = { nil, stdout, stderr },
function(code, signal) env = env,
cwd = plugin_path,
}, function(code, signal)
if buf ~= '' and opts.on_event then if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf) local ok_tail, ev_tail = pcall(vim.json.decode, buf)
if ok_tail then if ok_tail then
@ -64,8 +78,7 @@ local function run_scraper(platform, subcommand, args, opts)
if handle and not handle:is_closing() then if handle and not handle:is_closing() then
handle:close() handle:close()
end end
end end)
)
if not handle then if not handle then
logger.log('Failed to start scraper process', vim.log.levels.ERROR) logger.log('Failed to start scraper process', vim.log.levels.ERROR)
@ -102,7 +115,7 @@ local function run_scraper(platform, subcommand, args, opts)
return return
end 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 if opts and opts.sync then
local result = vim.system(cmd, sysopts):wait() local result = vim.system(cmd, sysopts):wait()
return syshandle(result) return syshandle(result)

View file

@ -160,6 +160,8 @@ function M.setup_contest(platform, contest_id, problem_id, language)
vim.bo[bufnr].buftype = '' vim.bo[bufnr].buftype = ''
vim.bo[bufnr].swapfile = false 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 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) local ok = pcall(cfg.hooks.setup_code, state)
if ok then if ok then

View file

@ -121,6 +121,14 @@ function M.toggle_interactive(interactor_cmd)
end end
local orchestrator = local orchestrator =
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p') vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
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({ cmdline = table.concat({
'uv', 'uv',
'run', 'run',
@ -128,6 +136,7 @@ function M.toggle_interactive(interactor_cmd)
vim.fn.shellescape(interactor), vim.fn.shellescape(interactor),
vim.fn.shellescape(binary), vim.fn.shellescape(binary),
}, ' ') }, ' ')
end
else else
cmdline = vim.fn.shellescape(binary) cmdline = vim.fn.shellescape(binary)
end end

View file

@ -2,6 +2,9 @@ local M = {}
local logger = require('cp.log') local logger = require('cp.log')
local _nix_python = nil
local _nix_discovered = false
local uname = vim.loop.os_uname() local uname = vim.loop.os_uname()
local _time_cached = false local _time_cached = false
@ -57,7 +60,11 @@ local function find_gnu_time()
_time_cached = true _time_cached = true
_time_path = nil _time_path = nil
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' _time_reason = 'GNU time not found'
end
return _time_path, _time_reason return _time_path, _time_reason
end end
@ -79,27 +86,101 @@ function M.get_plugin_path()
return vim.fn.fnamemodify(plugin_path, ':h:h:h') return vim.fn.fnamemodify(plugin_path, ':h:h:h')
end 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 }
end
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
end
local python_env_setup = false 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'
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()
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' },
{ 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
_nix_discovered = true
return true
end
---@return boolean success ---@return boolean success
function M.setup_python_env() function M.setup_python_env()
if python_env_setup then if python_env_setup then
return true return true
end end
local plugin_path = M.get_plugin_path() if _nix_python then
local venv_dir = plugin_path .. '/.venv' logger.log('Python env: nix (python=' .. _nix_python .. ')')
python_env_setup = true
if vim.fn.executable('uv') == 0 then return true
logger.log(
'uv is not installed. Install it to enable problem scraping: https://docs.astral.sh/uv/',
vim.log.levels.WARN
)
return false
end end
if vim.fn.isdirectory(venv_dir) == 0 then if vim.fn.executable('uv') == 1 then
logger.log('Setting up Python environment for scrapers...') 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() local env = vim.fn.environ()
env.VIRTUAL_ENV = '' env.VIRTUAL_ENV = ''
env.PYTHONPATH = '' env.PYTHONPATH = ''
@ -108,16 +189,35 @@ function M.setup_python_env()
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
:wait() :wait()
if result.code ~= 0 then 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 return false
end end
logger.log('Python environment setup complete.') if result.stderr and result.stderr ~= '' then
logger.log('uv sync stderr: ' .. result.stderr:gsub('%s+$', ''))
end end
python_env_setup = true python_env_setup = true
return true return true
end 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
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 --- Configure the buffer with good defaults
---@param filetype? string ---@param filetype? string
function M.create_buffer_with_options(filetype) function M.create_buffer_with_options(filetype)
@ -162,20 +262,12 @@ function M.check_required_runtime()
local time = M.time_capability() local time = M.time_capability()
if not time.ok then if not time.ok then
return false, 'GNU time not found: ' .. (time.reason or '') return false, time.reason
end end
local timeout = M.timeout_capability() local timeout = M.timeout_capability()
if not timeout.ok then if not timeout.ok then
return false, 'GNU timeout not found: ' .. (timeout.reason or '') return false, timeout.reason
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'
end end
return true return true
@ -225,7 +317,11 @@ local function find_gnu_timeout()
_timeout_cached = true _timeout_cached = true
_timeout_path = nil _timeout_path = nil
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' _timeout_reason = 'GNU timeout not found'
end
return _timeout_path, _timeout_reason return _timeout_path, _timeout_reason
end end

0
new
View file

View file

@ -154,3 +154,17 @@ end, {
return {} return {}
end, end,
}) })
local function cp_action(action)
return function()
require('cp').handle_command({ fargs = { action } })
end
end
vim.keymap.set('n', '<Plug>(cp-run)', cp_action('run'), { desc = 'CP run tests' })
vim.keymap.set('n', '<Plug>(cp-panel)', cp_action('panel'), { desc = 'CP open panel' })
vim.keymap.set('n', '<Plug>(cp-edit)', cp_action('edit'), { desc = 'CP edit test cases' })
vim.keymap.set('n', '<Plug>(cp-next)', cp_action('next'), { desc = 'CP next problem' })
vim.keymap.set('n', '<Plug>(cp-prev)', cp_action('prev'), { desc = 'CP previous problem' })
vim.keymap.set('n', '<Plug>(cp-pick)', cp_action('pick'), { desc = 'CP pick contest' })
vim.keymap.set('n', '<Plug>(cp-interact)', cp_action('interact'), { desc = 'CP interactive mode' })

View file

@ -12,8 +12,6 @@ dependencies = [
"ndjson>=0.3.1", "ndjson>=0.3.1",
"pydantic>=2.11.10", "pydantic>=2.11.10",
"requests>=2.32.5", "requests>=2.32.5",
"scrapling[fetchers]>=0.3.5",
"types-requests>=2.32.4.20250913",
] ]
[dependency-groups] [dependency-groups]

View file

@ -6,7 +6,7 @@ import re
from typing import Any from typing import Any
import httpx import httpx
from scrapling.fetchers import Fetcher from curl_cffi import requests as curl_requests
from .base import BaseScraper from .base import BaseScraper
from .models import ( from .models import (
@ -50,8 +50,9 @@ def _extract_memory_limit(html: str) -> float:
def _fetch_html_sync(url: str) -> str: def _fetch_html_sync(url: str) -> str:
response = Fetcher.get(url) response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_S)
return str(response.body) response.raise_for_status()
return response.text
class CodeChefScraper(BaseScraper): class CodeChefScraper(BaseScraper):

View file

@ -2,13 +2,12 @@
import asyncio import asyncio
import json import json
import logging
import re import re
from typing import Any from typing import Any
import requests import requests
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from scrapling.fetchers import Fetcher from curl_cffi import requests as curl_requests
from .base import BaseScraper from .base import BaseScraper
from .models import ( from .models import (
@ -19,10 +18,6 @@ from .models import (
TestCase, TestCase,
) )
# suppress scrapling logging - https://github.com/D4Vinci/Scrapling/issues/31)
logging.getLogger("scrapling").setLevel(logging.CRITICAL)
BASE_URL = "https://codeforces.com" BASE_URL = "https://codeforces.com"
API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list" API_CONTEST_LIST_URL = f"{BASE_URL}/api/contest.list"
TIMEOUT_SECONDS = 30 TIMEOUT_SECONDS = 30
@ -83,7 +78,7 @@ def _extract_title(block: Tag) -> tuple[str, str]:
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]: def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
st = block.find("div", class_="sample-test") st = block.find("div", class_="sample-test")
if not st: if not isinstance(st, Tag):
return [], False return [], False
input_pres: list[Tag] = [ input_pres: list[Tag] = [
@ -140,10 +135,9 @@ def _is_interactive(block: Tag) -> bool:
def _fetch_problems_html(contest_id: str) -> str: def _fetch_problems_html(contest_id: str) -> str:
url = f"{BASE_URL}/contest/{contest_id}/problems" url = f"{BASE_URL}/contest/{contest_id}/problems"
page = Fetcher.get( response = curl_requests.get(url, impersonate="chrome", timeout=TIMEOUT_SECONDS)
url, response.raise_for_status()
) return response.text
return page.html_content
def _parse_all_blocks(html: str) -> list[dict[str, Any]]: def _parse_all_blocks(html: str) -> list[dict[str, Any]]:

View file

@ -10,7 +10,7 @@ from typing import Any
import httpx import httpx
import pytest import pytest
import requests import requests
from scrapling import fetchers from curl_cffi import requests as curl_requests
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
FIX = Path(__file__).resolve().parent / "fixtures" FIX = Path(__file__).resolve().parent / "fixtures"
@ -136,12 +136,15 @@ def run_scraper_offline(fixture_text):
case "codeforces": case "codeforces":
class MockCodeForcesPage: class MockCurlResponse:
def __init__(self, html: str): def __init__(self, html: str):
self.html_content = html self.text = html
def _mock_stealthy_fetch(url: str, **kwargs): def raise_for_status(self):
return MockCodeForcesPage(_router_codeforces(url=url)) pass
def _mock_curl_get(url: str, **kwargs):
return MockCurlResponse(_router_codeforces(url=url))
def _mock_requests_get(url: str, **kwargs): def _mock_requests_get(url: str, **kwargs):
if "api/contest.list" in url: if "api/contest.list" in url:
@ -172,7 +175,7 @@ def run_scraper_offline(fixture_text):
raise AssertionError(f"Unexpected requests.get call: {url}") raise AssertionError(f"Unexpected requests.get call: {url}")
return { return {
"Fetcher.get": _mock_stealthy_fetch, "curl_requests.get": _mock_curl_get,
"requests.get": _mock_requests_get, "requests.get": _mock_requests_get,
} }
@ -212,21 +215,23 @@ def run_scraper_offline(fixture_text):
return MockResponse(data) return MockResponse(data)
raise AssertionError(f"No fixture for CodeChef url={url!r}") raise AssertionError(f"No fixture for CodeChef url={url!r}")
class MockCodeChefPage: class MockCodeChefCurlResponse:
def __init__(self, html: str): def __init__(self, html: str):
self.body = html self.text = html
self.status = 200
def _mock_stealthy_fetch(url: str, **kwargs): def raise_for_status(self):
pass
def _mock_curl_get(url: str, **kwargs):
if "/problems/" in url: if "/problems/" in url:
problem_id = url.rstrip("/").split("/")[-1] problem_id = url.rstrip("/").split("/")[-1]
html = fixture_text(f"codechef/{problem_id}.html") html = fixture_text(f"codechef/{problem_id}.html")
return MockCodeChefPage(html) return MockCodeChefCurlResponse(html)
raise AssertionError(f"No fixture for CodeChef url={url!r}") raise AssertionError(f"No fixture for CodeChef url={url!r}")
return { return {
"__offline_get_async": __offline_get_async, "__offline_get_async": __offline_get_async,
"Fetcher.get": _mock_stealthy_fetch, "curl_requests.get": _mock_curl_get,
} }
case _: case _:
@ -245,7 +250,7 @@ def run_scraper_offline(fixture_text):
offline_fetches = _make_offline_fetches(scraper_name) offline_fetches = _make_offline_fetches(scraper_name)
if scraper_name == "codeforces": 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"] requests.get = offline_fetches["requests.get"]
elif scraper_name == "atcoder": elif scraper_name == "atcoder":
ns._fetch = offline_fetches["_fetch"] ns._fetch = offline_fetches["_fetch"]
@ -254,7 +259,7 @@ def run_scraper_offline(fixture_text):
httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"] httpx.AsyncClient.get = offline_fetches["__offline_fetch_text"]
elif scraper_name == "codechef": elif scraper_name == "codechef":
httpx.AsyncClient.get = offline_fetches["__offline_get_async"] 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_class = getattr(ns, scraper_classes[scraper_name])
scraper = scraper_class() scraper = scraper_class()

View file

@ -6,11 +6,6 @@ from scrapers.models import (
TestsResult, TestsResult,
) )
MODEL_FOR_MODE = {
"metadata": MetadataResult,
"contests": ContestListResult,
}
MATRIX = { MATRIX = {
"cses": { "cses": {
"metadata": ("introductory_problems",), "metadata": ("introductory_problems",),
@ -43,16 +38,15 @@ def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
assert rc in (0, 1), f"Bad exit code {rc}" assert rc in (0, 1), f"Bad exit code {rc}"
assert objs, f"No JSON output for {scraper}:{mode}" 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
assert model.success is True
if mode == "metadata": if mode == "metadata":
model = MetadataResult.model_validate(objs[-1])
assert model.success is True
assert model.url assert model.url
assert len(model.problems) >= 1 assert len(model.problems) >= 1
assert all(isinstance(p.id, str) and p.id for p in model.problems) assert all(isinstance(p.id, str) and p.id for p in model.problems)
else: elif mode == "contests":
model = ContestListResult.model_validate(objs[-1])
assert model.success is True
assert len(model.contests) >= 1 assert len(model.contests) >= 1
else: else:
assert len(objs) >= 1, "No test objects returned" assert len(objs) >= 1, "No test objects returned"

1269
uv.lock generated

File diff suppressed because it is too large Load diff