Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

96 changed files with 3500 additions and 47898 deletions

13
.busted Normal file
View file

@ -0,0 +1,13 @@
return {
_all = {
coverage = false,
lpath = 'lua/?.lua;lua/?/init.lua',
lua = 'nlua',
},
default = {
verbose = true,
},
tests = {
verbose = true,
},
}

View file

@ -1,78 +0,0 @@
name: Bug Report
description: Report a bug
title: 'bug: '
labels: [bug]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/cp.nvim/issues)
required: true
- label: I have updated to the latest version
required: true
- type: textarea
attributes:
label: 'Neovim version'
description: 'Output of `nvim --version`'
render: text
validations:
required: true
- type: input
attributes:
label: 'Operating system'
placeholder: 'e.g. Arch Linux, macOS 15, Ubuntu 24.04'
validations:
required: true
- type: textarea
attributes:
label: Description
description: What happened? What did you expect?
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: Minimal steps to trigger the bug
value: |
1.
2.
3.
validations:
required: true
- type: textarea
attributes:
label: 'Health check'
description: 'Output of `:checkhealth cp`'
render: text
- type: textarea
attributes:
label: Minimal reproduction
description: |
Save the script below as `repro.lua`, edit if needed, and run:
```
nvim -u repro.lua
```
Confirm the bug reproduces with this config before submitting.
render: lua
value: |
vim.env.LAZY_STDPATH = '.repro'
load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))()
require('lazy.nvim').setup({
spec = {
{
'barrett-ruth/cp.nvim',
opts = {},
},
},
})
validations:
required: true

View file

@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/barrettruth/cp.nvim/discussions
about: Ask questions and discuss ideas

View file

@ -1,30 +0,0 @@
name: Feature Request
description: Suggest a feature
title: 'feat: '
labels: [enhancement]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label:
I have searched [existing
issues](https://github.com/barrettruth/cp.nvim/issues)
required: true
- type: textarea
attributes:
label: Problem
description: What problem does this solve?
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered

66
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lua-format:
name: Lua Formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --check .
lua-lint:
name: Lua Linting
runs-on: ubuntu-latest
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 .
lua-typecheck:
name: Lua Type Checking
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Lua LS Type Check
uses: mrcjkb/lua-typecheck-action@v0
with:
checklevel: Warning
directories: lua
configpath: .luarc.json
python-format:
name: Python Formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install ruff
run: uv tool install ruff
- name: Check Python formatting with ruff
run: ruff format --check scrapers/
python-lint:
name: Python Linting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install ruff
run: uv tool install ruff
- name: Lint Python files with ruff
run: ruff check scrapers/

View file

@ -1,21 +0,0 @@
name: luarocks
on:
push:
tags:
- 'v*'
jobs:
ci:
uses: ./.github/workflows/ci.yaml
publish:
needs: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nvim-neorocks/luarocks-tag-release@v7
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}

17
.github/workflows/luarocks.yml vendored Normal file
View file

@ -0,0 +1,17 @@
name: Push to Luarocks
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
luarocks-upload:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: LuaRocks Upload
uses: nvim-neorocks/luarocks-tag-release@v7
env:
LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }}

View file

@ -1,141 +0,0 @@
name: quality
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
lua: ${{ steps.changes.outputs.lua }}
python: ${{ steps.changes.outputs.python }}
markdown: ${{ steps.changes.outputs.markdown }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: changes
with:
filters: |
lua:
- 'lua/**'
- 'spec/**'
- 'plugin/**'
- 'after/**'
- 'ftdetect/**'
- '*.lua'
- '.luarc.json'
- '*.toml'
python:
- 'scripts/**/.py'
- 'scrapers/**/*.py'
- 'tests/**/*.py'
- 'pyproject.toml'
- 'uv.lock'
markdown:
- '*.md'
- 'docs/**/*.md'
lua-format:
name: Lua Format Check
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:
name: Lua Lint Check
runs-on: ubuntu-latest
needs: changes
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 .
lua-typecheck:
name: Lua Type Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.lua == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Run Lua LS Type Check
uses: mrcjkb/lua-typecheck-action@v0
with:
checklevel: Warning
directories: lua
configpath: .luarc.json
python-format:
name: Python Format Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install ruff
run: uv tool install ruff
- name: Check Python formatting with ruff
run: ruff format --check .
python-lint:
name: Python Lint Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install ruff
run: uv tool install ruff
- name: Lint Python files with ruff
run: ruff check .
python-typecheck:
name: Python Type Check
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies with uv
run: uv sync --dev
- name: Type check Python files with ty
run: uvx ty check .
markdown-format:
name: Markdown Format Check
runs-on: ubuntu-latest
needs: changes
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 .

View file

@ -1,50 +0,0 @@
name: tests
on:
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'
python-test:
name: Python Tests
runs-on: ubuntu-latest
needs: changes
if: ${{ needs.changes.outputs.python == 'true' }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Run Python tests
run: uv run pytest tests/ -v

21
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Run tests
on:
pull_request: ~
push:
branches:
- main
jobs:
build:
name: Run tests
runs-on: ubuntu-latest
strategy:
matrix:
neovim_version: ['nightly', 'stable']
steps:
- uses: actions/checkout@v4
- name: Run tests
uses: nvim-neorocks/nvim-busted-action@v1
with:
nvim_version: ${{ matrix.neovim_version }}

16
.gitignore vendored
View file

@ -1,19 +1,7 @@
.venv .venv/
venv
doc/tags doc/tags
*.log *.log
build build
io
debug debug
create venv/
.*cache*
CLAUDE.md CLAUDE.md
__pycache__
.claude/
node_modules/
.envrc
.direnv/

View file

@ -1,8 +1,16 @@
{ {
"runtime.version": "Lua 5.1", "runtime.version": "Lua 5.1",
"runtime.path": ["lua/?.lua", "lua/?/init.lua"], "runtime.path": [
"diagnostics.globals": ["vim"], "lua/?.lua",
"workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], "lua/?/init.lua"
],
"diagnostics.globals": [
"vim"
],
"workspace.library": [
"$VIMRUNTIME/lua",
"${3rd}/luv/library"
],
"workspace.checkThirdParty": false, "workspace.checkThirdParty": false,
"completion.callSnippet": "Replace" "completion.callSnippet": "Replace"
} }

View file

@ -1,36 +0,0 @@
minimum_pre_commit_version: '3.5.0'
repos:
- repo: https://github.com/JohnnyMorganz/StyLua
rev: v2.3.1
hooks:
- id: stylua-github
name: stylua (Lua formatter)
files: \.lua$
pass_filenames: true
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.3
hooks:
- id: ruff-format
name: ruff (format)
files: \.py$
- id: ruff
name: ruff (lint imports)
args: ['--fix', '--select=I']
files: \.py$
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
name: prettier
files: \.(md|toml|ya?ml|sh)$
- repo: local
hooks:
- id: ty-type-check
name: ty (Python type checker)
language: system
entry: uv run ty check
types: [python]

View file

@ -1,8 +0,0 @@
.pytest_cache/
node_modules/
.venv/
build/
dist/
*.pyc
__pycache__/
tests/fixtures/

View file

@ -1,17 +0,0 @@
{
"proseWrap": "always",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "none",
"semi": false,
"singleQuote": true,
"overrides": [
{
"files": ["**/*.md"],
"options": {
"parser": "markdown"
}
}
]
}

100
README.md
View file

@ -1,100 +0,0 @@
# cp.nvim
**The definitive competitive programming environment for Neovim**
Scrape problems, run tests, and debug solutions across multiple platforms with
zero configuration.
https://github.com/user-attachments/assets/e81d8dfb-578f-4a79-9989-210164fc0148
## Features
- **Multi-platform support**: AtCoder, CodeChef, Codeforces, and CSES
- **Automatic problem setup**: Scrape test cases and metadata in seconds
- **Dual view modes**: Lightweight I/O view for quick feedback, full panel for
detailed analysis
- **Test case management**: Quickly view, edit, add, & remove test cases
- **Rich test output**: 256 color ANSI support for compiler errors and program
output
- **Language agnostic**: Works with any language
- **Diff viewer**: Compare expected vs actual output with 3 diff modes
## Installation
Install using your package manager of choice or via
[luarocks](https://luarocks.org/modules/barrettruth/cp.nvim):
```
luarocks install cp.nvim
```
## Dependencies
- 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
cp.nvim follows a simple principle: **solve locally, submit remotely**.
### Basic Usage
1. Find a contest or problem
2. Set up contests locally
```
:CP codeforces 1848
```
3. Code and test
```
:CP run
```
4. Navigate between problems
```
:CP next
:CP prev
:CP e1
```
5. Debug and edit test cases
```
:CP edit
:CP panel --debug
```
5. Submit on the original website
## Documentation
```vim
:help cp.nvim
```
See
[my config](https://github.com/barrettruth/dots/blob/main/.config/nvim/lua/plugins/cp.lua)
for the setup in the video shown above.
## Motivation
I could not find a neovim-centric, efficient, dependency-free, flexible, and
easily customizable competitive programming workflow that "just works"--so I
made it myself. I conferenced with top competitive programmers at Carnegie
Mellon Univerity and the University of Virginia and covered their (and my) pain
points:
- Scraping: contests are automatically loaded asynchronously
- Test Case Management: test case editor (`:CP edit`)
- UI: both `run` and `panel` layouts cover common formats
- Extensibility: snippet plugins, compilation, etc. are left to the programmer
## Similar Projects
- [competitest.nvim](https://github.com/xeluxee/competitest.nvim)
- [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim)

View file

@ -4,4 +4,3 @@ vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no' vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true vim.opt_local.wrap = true
vim.opt_local.linebreak = true vim.opt_local.linebreak = true
vim.opt_local.foldcolumn = '0'

6
after/ftplugin/cpin.lua Normal file
View file

@ -0,0 +1,6 @@
vim.opt_local.number = false
vim.opt_local.relativenumber = false
vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true
vim.opt_local.linebreak = true

7
after/ftplugin/cpout.lua Normal file
View file

@ -0,0 +1,7 @@
vim.opt_local.number = false
vim.opt_local.relativenumber = false
vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.modifiable = true

View file

@ -0,0 +1,7 @@
vim.opt_local.number = false
vim.opt_local.relativenumber = false
vim.opt_local.statuscolumn = ''
vim.opt_local.signcolumn = 'no'
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.foldcolumn = '0'

17
after/syntax/cp.vim Normal file
View file

@ -0,0 +1,17 @@
if exists("b:current_syntax")
finish
endif
syntax match cpOutputCode /^\[code\]:/
syntax match cpOutputTime /^\[time\]:/
syntax match cpOutputDebug /^\[debug\]:/
syntax match cpOutputOkTrue /^\[ok\]:\ze true$/
syntax match cpOutputOkFalse /^\[ok\]:\ze false$/
highlight default link cpOutputCode DiagnosticInfo
highlight default link cpOutputTime Comment
highlight default link cpOutputDebug Comment
highlight default link cpOutputOkTrue DiffAdd
highlight default link cpOutputOkFalse DiffDelete
let b:current_syntax = "cp"

View file

@ -2,7 +2,7 @@ rockspec_format = '3.0'
package = 'cp.nvim' package = 'cp.nvim'
version = 'scm-1' version = 'scm-1'
source = { url = 'git://github.com/barrettruth/cp.nvim' } source = { url = 'git://github.com/barrett-ruth/cp.nvim' }
build = { type = 'builtin' } build = { type = 'builtin' }
test_dependencies = { test_dependencies = {

View file

@ -1,937 +0,0 @@
*cp.nvim.txt* Competitive programming plugin for Neovim
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: Same terms as Vim itself (see |license|)
==============================================================================
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
==============================================================================
REQUIREMENTS *cp-requirements*
- Neovim 0.10.0+
- Unix-like operating system
- 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*
: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/<name>.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
<c-w> 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/<name>.run
• Debug build: commands.debug → outputs to build/<name>.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 <Plug> mappings for all primary actions. These dispatch
through the same code path as |:CP|.
*<Plug>(cp-run)*
<Plug>(cp-run) Run tests in I/O view. Equivalent to :CP run.
*<Plug>(cp-panel)*
<Plug>(cp-panel) Open full-screen test panel. Equivalent to :CP panel.
*<Plug>(cp-edit)*
<Plug>(cp-edit) Open the test case editor. Equivalent to :CP edit.
*<Plug>(cp-next)*
<Plug>(cp-next) Navigate to the next problem. Equivalent to :CP next.
*<Plug>(cp-prev)*
<Plug>(cp-prev) Navigate to the previous problem. Equivalent to :CP prev.
*<Plug>(cp-pick)*
<Plug>(cp-pick) Launch the contest picker. Equivalent to :CP pick.
*<Plug>(cp-interact)*
<Plug>(cp-interact) Open interactive mode. Equivalent to :CP interact.
Example configuration: >lua
vim.keymap.set('n', '<leader>cr', '<Plug>(cp-run)')
vim.keymap.set('n', '<leader>cp', '<Plug>(cp-panel)')
vim.keymap.set('n', '<leader>ce', '<Plug>(cp-edit)')
vim.keymap.set('n', '<leader>cn', '<Plug>(cp-next)')
vim.keymap.set('n', '<leader>cN', '<Plug>(cp-prev)')
vim.keymap.set('n', '<leader>cc', '<Plug>(cp-pick)')
vim.keymap.set('n', '<leader>ci', '<Plug>(cp-interact)')
<
==============================================================================
LANGUAGE SELECTION *cp-lang-selection*
cp.nvim supports multiple languages per problem. Each platform enables specific
languages and has a default. You can override the language for any setup or
navigation command using the --lang flag.
Language Selection Behavior ~
When setting up or navigating to a problem:
1. Explicit --lang flag takes highest priority
2. If no --lang flag, tries to preserve current file's language
(only if that language is enabled for the new problem)
3. Falls back to platform's default language
Multiple Solution Files ~
Different languages create different solution files. For example:
1848a.cc (C++ solution)
1848a.py (Python solution)
Both files can exist simultaneously with their own state. Switching between
languages means switching between different files.
Examples ~
>
:CP codeforces 1848 " Use platform default (likely C++)
:CP codeforces 1848 --lang python " Use Python explicitly
" In 1848a.cc (C++ file):
:CP next " Next problem tries to use C++
:CP next --lang python " Next problem uses Python
" In 1848a.py (Python file):
:CP next " Next problem tries to use Python
:CP next --lang cpp " Next problem switches to C++
<
Language Validation ~
If you request a language that isn't enabled for a platform, cp.nvim will show
a helpful error message listing available languages for that platform.
==============================================================================
WORKFLOW *cp-workflow*
For the sake of consistency and simplicity, cp.nvim extracts contest/problem
identifiers from URLs. This means that, for example, CodeForces/AtCoder
contests are configured by their round id rather than round number. See below.
==============================================================================
PLATFORM-SPECIFIC USAGE *cp-platforms*
AtCoder ~
*cp-atcoder*
URL format:
https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_{problem_id}
Usage examples: >
:CP atcoder abc324 " Set up atcoder.jp/contests/abc324
:CP atcoder abc324 --lang python " Set up with Python instead of default
Codeforces ~
*cp-codeforces*
URL format: https://codeforces.com/contest/{contest_id}/problem/{problem_id}
Usage examples: >
:CP codeforces 1934 " Set up codeforces.com/contest/1934
:CP codeforces 1934 --lang cpp " Set up with C++
CSES ~
*cp-cses*
URL format: https://cses.fi/problemset/task/{problem_id}
Usage examples: >
:CP cses dynamic_programming " Set up all problems in dp category
==============================================================================
COMPLETE WORKFLOW EXAMPLE *cp-example*
Example: Setting up and solving AtCoder contest ABC324
1. Browse to https://atcoder.jp/contests/abc324
2. Set up entire contest (bulk setup): >
:CP atcoder abc324
< This scrapes all test case data, downloads all test cases,
and opens the first problem.
3. Code your solution, then test: >
:CP run
< View test verdicts in I/O splits. For detailed analysis: >
:CP panel
< Navigate tests with <c-n>/<c-p>, exit with q
4. Move to next problem: >
:CP next
< This automatically sets up the next problem (likely problem B)
5. Continue solving problems with :CP next/:CP prev navigation
6. Try a different language for a problem: >
:CP C --lang python
< Opens problem C with Python instead of C++
7. Switch to another file (e.g. previous contest): >
:e ~/contests/abc323/a.cpp
:CP
< Automatically restores abc323 contest context
8. Submit solutions on AtCoder website
==============================================================================
I/O VIEW *cp-io-view*
The I/O view provides lightweight test feedback in persistent side splits.
Test outputs are concatenated with verdict summaries at the bottom.
The |cp-panel| offers more fine-grained analysis with diff modes.
Execution Modes ~
The I/O view supports two execution modes:
Combined Mode (:CP run with single sample)
• Single execution with all test inputs concatenated
• Matches platform behavior (e.g. Codeforces multi-test format)
• Shows one verdict for the entire execution
• Input split: All test inputs concatenated
• Output split: Single program output + verdict
• Used when problem has one sample containing multiple test cases
Individual Mode (:CP run all / :CP run n / :CP run n,m,...)
• Separate execution for each test case
• Per-test verdicts for debugging
• Input split: Selected test inputs concatenated
• Output split: All test outputs concatenated + per-test verdicts
• Auto-selected when problem has multiple independent samples
Layout ~
The I/O view appears as 30% width splits on the right side: >
┌──────────────────────────┬─────────────────────────────────────────────┐
│ │ Output (Top Split) │
│ │ 5 510 │
│ │ │
│ │ 7 714 │
│ Solution Code │ │
│ │ Test 1: WA | 212.07/2000 ms | 1/512 MB |...│
│ │ Test 2: WA | 81.94/2000 ms | 1/512 MB |...│
│ ├─────────────────────────────────────────────┤
│ │ Input (Bottom Split) │
│ │ 1 2 3 │
│ │ │
│ │ 4 5 6 │
└──────────────────────────┴─────────────────────────────────────────────┘
<
The output split shows:
1. Program output (raw, preserving all formatting)
2. Space-aligned verdict summary with:
- Test number and status (AC/WA/TLE/MLE/RTE with color highlighting)
- Runtime: actual/limit in milliseconds
- Memory: actual/limit in megabytes
- Exit code (with signal name for crashes)
Usage ~
:CP run Combined mode: all tests in one execution
:CP run all Individual mode: all tests separately
:CP run 3 Individual mode: test 3 only
:CP run 1,3,5 Individual mode: specific tests (1, 3, and 5)
Navigation ~
While in the I/O view buffers, use the configured keymaps to cycle through tests:
<c-n> Next test (default, see |RunConfig|.next_test_key)
<c-p> Previous test (default, see |RunConfig|.prev_test_key)
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|).
==============================================================================
VERDICT FORMATTING *cp-verdict-format*
Customize how verdict summaries appear in the I/O view using format_verdict.
Configuration ~
Set ui.run.format_verdict to a function that formats verdict data: >lua
format_verdict = function(data)
return { line = "...", highlights = {...} }
end
<
Format Function ~
*VerdictFormatter*
Input: |VerdictFormatData| table with test results
Output: |VerdictFormatResult| table with formatted line and optional highlights
*VerdictFormatData*
{index} (integer) Test case number
{status} (table) { text: string, highlight_group: string }
{time_ms} (number) Execution time in milliseconds
{time_limit_ms} (number) Time limit in milliseconds
{memory_mb} (number) Peak memory usage in megabytes
{memory_limit_mb} (number) Memory limit in megabytes
{exit_code} (integer) Process exit code
{signal} (string|nil) Signal name for crashes (e.g. "SIGSEGV")
{time_actual_width} (integer|nil) Dynamic width for time value alignment
{time_limit_width} (integer|nil) Dynamic width for time limit alignment
{mem_actual_width} (integer|nil) Dynamic width for memory value alignment
{mem_limit_width} (integer|nil) Dynamic width for memory limit alignment
*VerdictFormatResult*
{line} (string) The formatted verdict line
{highlights} (table[], optional) Highlight regions:
{col_start} (integer) Start column (0-indexed)
{col_end} (integer) End column (exclusive)
{group} (string) Highlight group name
Examples ~
Minimal format: >lua
format_verdict = function(data)
return {
line = string.format("#%d %s", data.index, data.status.text)
}
end
<
See |cp-helpers| for alignment functions: pad_right, pad_left, center.
==============================================================================
PICKER INTEGRATION *cp-picker*
When picker integration is enabled in configuration, cp.nvim provides interactive
platform and contest selection using telescope.nvim or fzf-lua.
:CP pick *:CP-pick*
Launch configured picker for interactive problem selection.
Control Flow: Select Platform → Contest → Code!
Requires picker = 'telescope' or picker = 'fzf-lua' in configuration.
Requires corresponding plugin (telescope.nvim or fzf-lua) to be installed.
PICKER KEYMAPS *cp-picker-keys*
<c-r> Force refresh/update contest list.
Useful when contest lists are outdated or incomplete
==============================================================================
PANEL *cp-panel*
The panel provides full-screen test analysis with diff modes for detailed
debugging. Problem time/memory limit constraints are in columns Time/Mem
respectively. Used time/memory are in columns Runtime/RSS respectively.
Access with :CP panel or :CP panel --debug (uses debug build configuration).
Interface ~
The panel uses the following table layout: >
┌─────┬────────┬──────────────┬───────────┬──────────┬──────────┬─────────────┐
│ # │ Status │ Runtime (ms) │ Time (ms) │ RSS (MB) │ Mem (MB) │ Exit Code │
├─────┼────────┼──────────────┼───────────┼──────────┼──────────┼─────────────┤
│ 1 │ AC │ 12.0 │ 2000 │ 123 │ 256 │ 0 │
│ >2 │ WA │ 45.70 │ 2000 │ 100 │ 256 │ 1 │
├─────┴────────┴──────────────┴───────────┴──────────┴──────────┴─────────────┤
│ Input: │
│ 5 3 │
├─────┬────────┬──────────────┬───────────┬──────────┬──────────┬─────────────┤
│ 3 │ TLE │ 9.0 │ 2000 │ 256 │ 256 │ 136 (SIGBUS)│
│ 4 │ RTE │ 0.0 │ 2000 │ 256 │ 256 │139 (SIGUSR2)│
└─────┴────────┴──────────────┴───────────┴──────────┴──────────┴─────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Expected vs Actual │
│ 423 │
│ 100 │
│ hello world │
└─────────────────────────────────────────────────────────────────────────────┘
Status Indicators ~
Test cases use competitive programming terminology with color highlighting:
AC Accepted (passed)
WA Wrong Answer (output mismatch)
TLE Time Limit Exceeded (timeout)
MLE Memory Limit Exceeded Error (heuristic)
RTE Runtime Error (other non-zero exit code)
NA Any other state
<
==============================================================================
INTERACTIVE MODE *cp-interact*
Run interactive problems manually or with an orchestrator. :CP interact is
available for interactive problems. Test cases are ignored in interactive mode
(no run panel, no diffs).
When using :CP interact {interactor}, the interactor must be executable
(chmod +x). Completion after :CP interact suggests executables in CWD.
1) Terminal-only ~
:CP interact
Execute the current program and open an interactive terminal running
it directly. Use this for manual testing.
2) Orchestrated ~
:CP interact {interactor}
Execute the current program and open an interactive terminal that runs
your interactor script against it.
{interactor} is an executable file relative to the CWD.
Example:
:CP interact my-executable-interactor.py
Keymaps ~
<c-q> Close the terminal and restore the previous layout.
==============================================================================
ANSI COLORS AND HIGHLIGHTING *cp-ansi*
cp.nvim provides comprehensive ANSI color support and highlighting for
compiler output, program stderr, and diff visualization.
If you cannot see color highlighting in your config, it is likely due to an
erroneous config. Most tools (GCC, Python, Clang, Rustc) color stdout based on
whether stdout is connected to a terminal. One can usually get aorund this by
leveraging flags to force colored output. For example, to force colors with GCC,
alter your config as follows:
>lua
{
commands = {
build = {
'g++',
'-fdiagnostics-color=always',
...
}
}
}
<
==============================================================================
HIGHLIGHT GROUPS *cp-highlights*
Test Status Groups ~
All test status groups link to builtin highlight groups, automatically adapting
to your colorscheme:
CpTestAC Links to DiagnosticOk (AC status)
CpTestWA Links to DiagnosticError (WA status)
CpTestTLE Links to DiagnosticWarn (TLE status)
CpTestMLE Links to DiagnosticWarn (MLE status)
CpTestRTE Links to DiagnosticHint (RTE status)
CpTestNA Links to Comment (pending/unknown status)
ANSI Color Groups ~
cp.nvim preserves ANSI colors from compiler output and program stderr using
a sophisticated parsing system. Colors are automatically mapped to your
terminal colorscheme via vim.g.terminal_color_* variables.
Diff Highlighting ~
The git diff backend uses Neovim's built-in highlight groups that automatically
adapt to your colorscheme:
DiffAdd Highlights added text in git diffs
DiffDelete Highlights removed text in git diffs
==============================================================================
TERMINAL COLOR INTEGRATION *cp-terminal-colors*
ANSI colors automatically use the terminal's color palette through Neovim's
vim.g.terminal_color_* variables.
==============================================================================
HIGHLIGHT CUSTOMIZATION *cp-highlight-custom*
Customize highlight groups after your colorscheme loads:
>lua
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
vim.api.nvim_set_hl(0, 'CpTestAC', { link = 'String' })
end
})
==============================================================================
HELPERS *cp-helpers*
The helpers module provides utility functions for buffer customization.
Access via:
>lua
local helpers = require('cp').helpers
<
Functions ~
helpers.clearcol({bufnr}) *helpers.clearcol*
Remove line numbers, columns, and signs from buffer.
Sets:
• number = false
• relativenumber = false
• signcolumn = 'no'
• statuscolumn = ''
Parameters: ~
{bufnr} (integer) Buffer handle
==============================================================================
PANEL KEYMAPS *cp-panel-keys*
<c-n> Navigate to next test case
<c-p> Navigate to previous test case
t Cycle through configured diff modes (see |cp.PanelConfig|)
q Exit panel and restore layout
<c-q> Exit interactive terminal and restore layout
Diff Modes ~
Three diff modes are available:
side-by-side Expected and actual output shown side-by-side (default)
vim Built-in vim diff (always available)
git Character-level git word-diff (requires git, more precise)
Configure which modes to cycle through via |cp.PanelConfig|.diff_modes.
The first element is used as the default mode.
The git backend shows character-level changes with [-removed-] and {+added+}
markers.
Execution Details ~
Test cases are executed individually using the same compilation and
execution pipeline, but with isolated input/output for
precise failure analysis.
==============================================================================
FILE STRUCTURE *cp-files*
cp.nvim creates the following file structure upon problem setup: >
{problem_id}.{ext} " Source file
build/
{problem_id}.run " Compiled binary
io/
{problem_id}.n.cpin " nth test input
{problem_id}.n.cpout " nth test expected output
<
==============================================================================
HEALTH CHECK *cp-health*
Run |:checkhealth| cp to verify your setup.
vim:tw=78:ts=8:ft=help:norl:

333
doc/cp.txt Normal file
View file

@ -0,0 +1,333 @@
*cp.txt* Competitive programming plugin for Neovim
Author: Barrett Ruth <br.barrettruth@gmail.com>
License: Same terms as Vim itself (see |license|)
INTRODUCTION *cp* *cp.nvim*
cp.nvim is a competitive programming plugin that automates problem setup,
compilation, and testing workflow for online judges.
Supported platforms: AtCoder, Codeforces, CSES
Supported languages: C++, Python
REQUIREMENTS *cp-requirements*
- Neovim 0.10.0+
- uv package manager (https://docs.astral.sh/uv/)
- Language runtime/compiler (g++, python3)
Optional:
- LuaSnip for template expansion (https://github.com/L3MON4D3/LuaSnip)
COMMANDS *cp-commands*
*:CP*
cp.nvim uses a single :CP command with intelligent argument parsing:
Setup Commands ~
:CP {platform} {contest_id} {problem_id} [--lang={language}]
Full setup: set platform, load contest metadata,
and set up specific problem. Scrapes test cases
and creates source file.
Example: :CP codeforces 1933 a
Example: :CP codeforces 1933 a --lang=python
:CP {platform} {contest_id} Contest setup: set platform and load contest
metadata for navigation. Caches problem list.
Example: :CP atcoder abc324
:CP {platform} Platform setup: set platform only.
Example: :CP cses
:CP {problem_id} [--lang={language}]
Problem switch: switch to different problem
within current contest context.
Example: :CP b (switch to problem b)
Example: :CP b --lang=python
Action Commands ~
:CP test [--debug] Toggle test panel for individual test case
debugging. Shows per-test results with three-pane
layout for easy Expected/Actual comparison.
Use --debug flag to compile with debug flags
Requires contest setup first.
Navigation Commands ~
:CP next Navigate to next problem in current contest.
Stops at last problem (no wrapping).
:CP prev Navigate to previous problem in current contest.
Stops at first problem (no wrapping).
CONFIGURATION *cp-config*
cp.nvim works out of the box. No setup required.
Optional configuration with lazy.nvim: >
{
'barrett-ruth/cp.nvim',
cmd = 'CP',
opts = {
debug = false,
scrapers = {
atcoder = true,
codeforces = false, -- disable codeforces scraping
cses = true,
},
contests = {
codeforces = {
cpp = {
compile = {
'g++', '-std=c++{version}', '-O2', '-Wall', '-Wextra',
'-DLOCAL', '{source}', '-o', '{binary}',
},
run = { '{binary}' },
debug = {
'g++', '-std=c++{version}', '-g3',
'-fsanitize=address,undefined', '-DLOCAL',
'{source}', '-o', '{binary}',
},
version = 23,
extension = "cc",
},
python = {
run = { 'python3', '{source}' },
debug = { 'python3', '{source}' },
extension = "py",
},
default_language = "cpp",
timeout_ms = 2000,
},
},
hooks = {
before_run = function(ctx) vim.cmd.w() end,
before_debug = function(ctx)
-- ctx.problem_id, ctx.platform, ctx.source_file, etc.
vim.cmd.w()
end,
setup_code = function(ctx)
vim.wo.foldmethod = "marker"
vim.wo.foldmarker = "{{{,}}}"
vim.diagnostic.enable(false)
end,
},
snippets = { ... }, -- LuaSnip snippets
filename = function(contest, contest_id, problem_id, config, language) ... end,
}
}
<
*cp.Config*
Fields: ~
• {contests} (`table<string,ContestConfig>`) Contest configurations.
• {hooks} (`cp.Hooks`) Hook functions called at various stages.
• {snippets} (`table[]`) LuaSnip snippet definitions.
• {debug} (`boolean`, default: `false`) Show info messages
during operation.
• {scrapers} (`table<string,boolean>`) Per-platform scraper control.
Default enables all platforms.
• {filename}? (`function`) Custom filename generation function.
`function(contest, contest_id, problem_id, config, language)`
Should return full filename with extension.
(default: concats contest_id and problem id)
*cp.ContestConfig*
Fields: ~
• {cpp} (`LanguageConfig`) C++ language configuration.
• {python} (`LanguageConfig`) Python language configuration.
• {default_language} (`string`, default: `"cpp"`) Default language when
`--lang` not specified.
• {timeout_ms} (`number`, default: `2000`) Execution timeout in
milliseconds.
*cp.LanguageConfig*
Fields: ~
• {compile}? (`string[]`) Compile command template with
`{version}`, `{source}`, `{binary}` placeholders.
• {run} (`string[]`) Run command template.
• {debug}? (`string[]`) Debug compile command template.
• {version}? (`number`) Language version (e.g. 20, 23 for C++).
• {extension} (`string`) File extension (e.g. "cc", "py").
• {executable}? (`string`) Executable name for interpreted languages.
*cp.Hooks*
Fields: ~
• {before_debug}? (`function`) Called before debug compilation.
`function(ctx: ProblemContext)`
• {setup_code}? (`function`) Called after source file is opened.
Used to configure buffer settings.
`function(ctx: ProblemContext)`
WORKFLOW *cp-workflow*
For the sake of consistency and simplicity, cp.nvim extracts contest/problem identifiers from
URLs. This means that, for example, CodeForces/AtCoder contests are configured by
their round id rather than round number. See below.
PLATFORM-SPECIFIC USAGE *cp-platforms*
AtCoder ~
*cp-atcoder*
URL format: https://atcoder.jp/contests/abc123/tasks/abc123_a
In terms of cp.nvim, this corresponds to:
- Platform: atcoder
- Contest ID: abc123
- Problem ID: a
Usage examples: >
:CP atcoder abc123 a " Full setup: problem A from contest ABC123
:CP atcoder abc123 " Contest setup: load contest metadata only
:CP b " Switch to problem B (if contest loaded)
:CP next " Navigate to next problem in contest
<
Codeforces ~
*cp-codeforces*
URL format: https://codeforces.com/contest/1234/problem/A
In terms of cp.nvim, this corresponds to:
- Platform: codeforces
- Contest ID: 1234
- Problem ID: a (lowercase)
Usage examples: >
:CP codeforces 1934 a " Full setup: problem A from contest 1934
:CP codeforces 1934 " Contest setup: load contest metadata only
:CP c " Switch to problem C (if contest loaded)
:CP prev " Navigate to previous problem in contest
<
CSES ~
*cp-cses*
URL format: https://cses.fi/problemset/task/1068
CSES is organized by categories rather than contests. Currently all problems
are grouped under "CSES Problem Set" category.
In terms of cp.nvim, this corresponds to:
- Platform: cses
- Contest ID: "CSES Problem Set" (category)
- Problem ID: 1068 (numeric)
Usage examples: >
:CP cses 1068 " Set up problem 1068 from CSES
:CP 1070 " Switch to problem 1070 (if CSES loaded)
:CP next " Navigate to next problem in CSES
<
COMPLETE WORKFLOW EXAMPLE *cp-example*
Example: Setting up and solving AtCoder contest ABC324
1. Browse to https://atcoder.jp/contests/abc324
2. Set up contest and load metadata: >
:CP atcoder abc324
< This caches all problems (A, B, C, D, E, F, G) for navigation
3. Start with problem A: >
:CP a
< This creates a.cc and scrapes test cases
4. Code your solution, then test: >
:CP test
< Navigate with j/k, run specific tests with <enter>
Exit test panel with q or :CP test when done
5. If needed, debug with sanitizers: >
:CP test --debug
<
6. Move to next problem: >
:CP next
< This automatically sets up problem B
6. Continue solving problems with :CP next/:CP prev navigation
7. Submit solutions on AtCoder website
Example: Quick setup for single Codeforces problem >
:CP codeforces 1933 a " One command setup
:CP test " Test immediately
<
TEST PANEL *cp-test*
The test panel provides individual test case debugging with a three-pane
layout showing test list, expected output, and actual output side-by-side.
Activation ~
*:CP-test*
:CP test [--debug] Toggle test panel on/off. When activated,
replaces current layout with test interface.
Automatically compiles and runs all tests.
Use --debug flag to compile with debug symbols
and sanitizers. Toggle again to restore original
layout.
Interface ~
The test panel uses a three-pane layout for easy comparison: >
┌─ Test List ─────────────────────────────────────────────────┐
│ 1. PASS 12ms │
│> 2. FAIL 45ms │
│ │
│ ── Input ── │
│ 5 3 │
│ │
└─────────────────────────────────────────────────────────────┘
┌─ Expected ──────────────┐ ┌─ Actual ────────────────┐
│ 8 │ │ 7 │
│ │ │ │
│ │ │ │
│ │ │ │
└─────────────────────────┘ └─────────────────────────┘
<
Keymaps ~
*cp-test-keys*
j / <Down> Navigate to next test case
k / <Up> Navigate to previous test case
q Exit test panel (restore layout)
Execution Details ~
Test cases are executed individually using the same compilation and
execution pipeline, but with isolated input/output for
precise failure analysis. All tests are automatically run when the
panel opens.
FILE STRUCTURE *cp-files*
cp.nvim creates the following file structure upon problem setup:
{problem_id}.{ext} " Source file (e.g. a.cc, b.py)
build/
{problem_id}.run " Compiled binary
io/
{problem_id}.cpin " Test input
{problem_id}.cpout " Program output
{problem_id}.expected " Expected output
The plugin automatically manages this structure and navigation between problems
maintains proper file associations.
SNIPPETS *cp-snippets*
cp.nvim integrates with LuaSnip for automatic template expansion. Built-in
snippets include basic C++ and Python templates for each contest type.
Snippet trigger names must EXACTLY match platform names ("codeforces" for
CodeForces, "cses" for CSES, etc.).
Custom snippets can be added via the `snippets` configuration field.
HEALTH CHECK *cp-health*
Run |:checkhealth| cp to verify your setup.
vim:tw=78:ts=8:ft=help:norl:

43
flake.lock generated
View file

@ -1,43 +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",
"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
}

View file

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

6
ftdetect/cp.lua Normal file
View file

@ -0,0 +1,6 @@
vim.filetype.add({
extension = {
cpin = 'cpin',
cpout = 'cpout',
},
})

View file

@ -1,65 +1,71 @@
---@class FileState ---@class CacheData
---@field platform string ---@field [string] table<string, ContestData>
---@field contest_id string
---@field problem_id? string
---@field language? string
---@class ContestData ---@class ContestData
---@field problems Problem[] ---@field problems Problem[]
---@field index_map table<string, number> ---@field scraped_at string
---@field name string ---@field expires_at? number
---@field display_name string ---@field test_cases? CachedTestCase[]
---@field url string ---@field test_cases_cached_at? number
---@class ContestSummary
---@field display_name string
---@field name string
---@field id string
---@class CombinedTest
---@field input string
---@field expected string
---@class Problem ---@class Problem
---@field id string ---@field id string
---@field name? string ---@field name? string
---@field interactive? boolean
---@field multi_test? boolean
---@field memory_mb? number
---@field timeout_ms? number
---@field combined_test? CombinedTest
---@field test_cases TestCase[]
---@class TestCase ---@class CachedTestCase
---@field index? number ---@field index? number
---@field input string
---@field expected? string ---@field expected? string
---@field input? string
---@field output? string ---@field output? string
local M = {} local M = {}
local logger = require('cp.log')
local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json' local cache_file = vim.fn.stdpath('data') .. '/cp-nvim.json'
local cache_data = {} local cache_data = {}
local loaded = false
--- Load the cache from disk if not done already ---@param platform string
---@return nil ---@return number?
function M.load() local function get_expiry_date(platform)
if loaded then vim.validate({
return platform = { platform, 'string' },
})
if platform == 'cses' then
return os.time() + (30 * 24 * 60 * 60)
end
return nil
end
---@param contest_data ContestData
---@param platform string
---@return boolean
local function is_cache_valid(contest_data, platform)
vim.validate({
contest_data = { contest_data, 'table' },
platform = { platform, 'string' },
})
if platform ~= 'cses' then
return true
end end
local expires_at = contest_data.expires_at
if not expires_at then
return false
end
return os.time() < expires_at
end
function M.load()
if vim.fn.filereadable(cache_file) == 0 then if vim.fn.filereadable(cache_file) == 0 then
vim.fn.writefile({}, cache_file) cache_data = {}
loaded = true
return return
end end
local content = vim.fn.readfile(cache_file) local content = vim.fn.readfile(cache_file)
if #content == 0 then if #content == 0 then
cache_data = {} cache_data = {}
loaded = true
return return
end end
@ -67,84 +73,61 @@ function M.load()
if ok then if ok then
cache_data = decoded cache_data = decoded
else else
logger.log('Could not decode json in cache file', vim.log.levels.ERROR) cache_data = {}
end end
loaded = true
end end
--- Save the cache to disk, overwriting existing contents
---@return nil
function M.save() function M.save()
vim.schedule(function() vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p')
vim.fn.mkdir(vim.fn.fnamemodify(cache_file, ':h'), 'p') local encoded = vim.json.encode(cache_data)
vim.fn.writefile(vim.split(encoded, '\n'), cache_file)
local encoded = vim.json.encode(cache_data)
local lines = vim.split(encoded, '\n')
vim.fn.writefile(lines, cache_file)
end)
end end
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@return ContestData ---@return ContestData?
function M.get_contest_data(platform, contest_id) function M.get_contest_data(platform, contest_id)
vim.validate({ vim.validate({
platform = { platform, 'string' }, platform = { platform, 'string' },
contest_id = { contest_id, 'string' }, contest_id = { contest_id, 'string' },
}) })
cache_data[platform] = cache_data[platform] or {}
cache_data[platform][contest_id] = cache_data[platform][contest_id] or {}
return cache_data[platform][contest_id]
end
---Get all cached contest IDs for a platform
---@param platform string
---@return string[]
function M.get_cached_contest_ids(platform)
vim.validate({
platform = { platform, 'string' },
})
if not cache_data[platform] then if not cache_data[platform] then
return {} return nil
end end
local contest_ids = {} local contest_data = cache_data[platform][contest_id]
for contest_id, _ in pairs(cache_data[platform]) do if not contest_data then
table.insert(contest_ids, contest_id) return nil
end end
table.sort(contest_ids)
return contest_ids if not is_cache_valid(contest_data, platform) then
return nil
end
return contest_data
end end
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@param problems Problem[] ---@param problems Problem[]
---@param url string function M.set_contest_data(platform, contest_id, problems)
function M.set_contest_data(platform, contest_id, problems, url)
vim.validate({ vim.validate({
platform = { platform, 'string' }, platform = { platform, 'string' },
contest_id = { contest_id, 'string' }, contest_id = { contest_id, 'string' },
problems = { problems, 'table' }, problems = { problems, 'table' },
url = { url, 'string' },
}) })
cache_data[platform] = cache_data[platform] or {} if not cache_data[platform] then
local prev = cache_data[platform][contest_id] or {} cache_data[platform] = {}
local out = {
name = prev.name,
display_name = prev.display_name,
problems = problems,
index_map = {},
url = url,
}
for i, p in ipairs(out.problems) do
out.index_map[p.id] = i
end end
cache_data[platform][contest_id] = out cache_data[platform][contest_id] = {
problems = problems,
scraped_at = os.date('%Y-%m-%d'),
expires_at = get_expiry_date(platform),
}
M.save() M.save()
end end
@ -165,7 +148,7 @@ end
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@param problem_id? string ---@param problem_id? string
---@return TestCase[] ---@return CachedTestCase[]?
function M.get_test_cases(platform, contest_id, problem_id) function M.get_test_cases(platform, contest_id, problem_id)
vim.validate({ vim.validate({
platform = { platform, 'string' }, platform = { platform, 'string' },
@ -173,171 +156,36 @@ function M.get_test_cases(platform, contest_id, problem_id)
problem_id = { problem_id, { 'string', 'nil' }, true }, problem_id = { problem_id, { 'string', 'nil' }, true },
}) })
if local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id
not cache_data[platform] if not cache_data[platform] or not cache_data[platform][problem_key] then
or not cache_data[platform][contest_id]
or not cache_data[platform][contest_id].problems
or not cache_data[platform][contest_id].index_map
then
return {}
end
local index = cache_data[platform][contest_id].index_map[problem_id]
return cache_data[platform][contest_id].problems[index].test_cases or {}
end
---@param platform string
---@param contest_id string
---@param problem_id? string
---@return CombinedTest?
function M.get_combined_test(platform, contest_id, problem_id)
if
not cache_data[platform]
or not cache_data[platform][contest_id]
or not cache_data[platform][contest_id].problems
or not cache_data[platform][contest_id].index_map
then
return nil return nil
end end
return cache_data[platform][problem_key].test_cases
local index = cache_data[platform][contest_id].index_map[problem_id]
return cache_data[platform][contest_id].problems[index].combined_test
end
---@param platform string
---@param contest_id string
---@param problem_id string
---@param combined_test? CombinedTest
---@param test_cases TestCase[]
---@param timeout_ms number
---@param memory_mb number
---@param interactive boolean
---@param multi_test boolean
function M.set_test_cases(
platform,
contest_id,
problem_id,
combined_test,
test_cases,
timeout_ms,
memory_mb,
interactive,
multi_test
)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
combined_test = { combined_test, { 'table', 'nil' }, true },
test_cases = { test_cases, 'table' },
timeout_ms = { timeout_ms, { 'number', 'nil' }, true },
memory_mb = { memory_mb, { 'number', 'nil' }, true },
interactive = { interactive, { 'boolean', 'nil' }, true },
multi_test = { multi_test, { 'boolean', 'nil' }, true },
})
local index = cache_data[platform][contest_id].index_map[problem_id]
cache_data[platform][contest_id].problems[index].combined_test = combined_test
cache_data[platform][contest_id].problems[index].test_cases = test_cases
cache_data[platform][contest_id].problems[index].timeout_ms = timeout_ms
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
M.save()
end end
---@param platform string ---@param platform string
---@param contest_id string ---@param contest_id string
---@param problem_id? string ---@param problem_id? string
---@return number?, number? ---@param test_cases CachedTestCase[]
function M.get_constraints(platform, contest_id, problem_id) function M.set_test_cases(platform, contest_id, problem_id, test_cases)
vim.validate({ vim.validate({
platform = { platform, 'string' }, platform = { platform, 'string' },
contest_id = { contest_id, 'string' }, contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true }, problem_id = { problem_id, { 'string', 'nil' }, true },
test_cases = { test_cases, 'table' },
}) })
local index = cache_data[platform][contest_id].index_map[problem_id] local problem_key = problem_id and (contest_id .. '_' .. problem_id) or contest_id
if not cache_data[platform] then
local problem_data = cache_data[platform][contest_id].problems[index] cache_data[platform] = {}
return problem_data.timeout_ms, problem_data.memory_mb
end
---@param file_path string
---@return FileState|nil
function M.get_file_state(file_path)
M.load()
cache_data.file_states = cache_data.file_states or {}
return cache_data.file_states[file_path]
end
---@param path string
---@param platform string
---@param contest_id string
---@param problem_id string
---@param language string|nil
function M.set_file_state(path, platform, contest_id, problem_id, language)
M.load()
cache_data.file_states = cache_data.file_states or {}
cache_data.file_states[path] = {
platform = platform,
contest_id = contest_id,
problem_id = problem_id,
language = language,
}
M.save()
end
---@param platform string
---@return ContestSummary[]
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,
})
end end
return contest_list if not cache_data[platform][problem_key] then
end cache_data[platform][problem_key] = {}
---@param platform string
---@param contests ContestSummary[]
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].name = contest.name
end end
cache_data[platform][problem_key].test_cases = test_cases
cache_data[platform][problem_key].test_cases_cached_at = os.time()
M.save() M.save()
end end
function M.clear_all()
cache_data = {}
M.save()
end
---@param platform string
function M.clear_platform(platform)
if cache_data[platform] then
cache_data[platform] = nil
end
M.save()
end
---@return string
function M.get_data_pretty()
M.load()
return vim.inspect(cache_data)
end
M._cache = cache_data
return M return M

View file

@ -1,74 +0,0 @@
local M = {}
local cache = require('cp.cache')
local constants = require('cp.constants')
local logger = require('cp.log')
local platforms = constants.PLATFORMS
--- Dispatch any `:CP cache ...` command
---@param cmd table
---@return nil
function M.handle_cache_command(cmd)
if cmd.subcommand == 'read' then
local data = cache.get_data_pretty()
local name = 'cp.nvim://cache.lua'
local existing = vim.fn.bufnr(name)
local buf
if existing ~= -1 then
buf = existing
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(data, '\n'))
else
buf = vim.api.nvim_create_buf(true, true)
vim.api.nvim_buf_set_name(buf, name)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(data, '\n'))
vim.bo[buf].filetype = 'lua'
vim.bo[buf].buftype = 'nofile'
vim.bo[buf].bufhidden = 'wipe'
vim.bo[buf].swapfile = false
vim.api.nvim_buf_set_keymap(
buf,
'n',
'q',
'<cmd>bd!<cr>',
{ nowait = true, noremap = true, silent = true }
)
end
vim.api.nvim_set_current_buf(buf)
elseif cmd.subcommand == 'clear' then
cache.load()
if cmd.platform and cmd.contest then
if vim.tbl_contains(platforms, cmd.platform) then
cache.clear_contest_data(cmd.platform, cmd.contest)
logger.log(
("Cache cleared for %s contest '%s'"):format(
constants.PLATFORM_DISPLAY_NAMES[cmd.platform],
cmd.contest
),
vim.log.levels.INFO,
true
)
else
logger.log(("Unknown platform '%s'."):format(cmd.platform), 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
)
else
logger.log(("Unknown platform '%s'."):format(cmd.platform), vim.log.levels.ERROR)
end
else
cache.clear_all()
logger.log('Cache cleared', vim.log.levels.INFO, true)
end
end
end
return M

View file

@ -1,327 +0,0 @@
local M = {}
local constants = require('cp.constants')
local logger = require('cp.log')
local state = require('cp.state')
local platforms = constants.PLATFORMS
local actions = constants.ACTIONS
---@class ParsedCommand
---@field type string
---@field error string?
---@field action? string
---@field message? string
---@field contest? string
---@field platform? string
---@field problem_id? string
---@field interactor_cmd? string
---@field test_index? integer
---@field test_indices? integer[]
---@field mode? string
---@field debug? boolean
---@field language? string
---@field subcommand? string
--- Turn raw args into normalized structure to later dispatch
---@param args string[] The raw command-line mode args
---@return ParsedCommand
local function parse_command(args)
if vim.tbl_isempty(args) then
return {
type = 'restore_from_file',
}
end
local first = args[1]
if vim.tbl_contains(actions, first) then
if first == 'cache' then
local subcommand = args[2]
if not subcommand then
return { type = 'error', message = 'cache command requires subcommand' }
end
if vim.tbl_contains({ 'clear', 'read' }, subcommand) then
local platform = args[3]
local contest = args[4]
return {
type = 'cache',
subcommand = subcommand,
platform = platform,
contest = contest,
}
else
return { type = 'error', message = 'unknown cache subcommand: ' .. subcommand }
end
elseif first == 'interact' then
local inter = args[2]
if inter and inter ~= '' then
return { type = 'action', action = 'interact', interactor_cmd = inter }
else
return { type = 'action', action = 'interact' }
end
elseif first == 'edit' then
local test_index = nil
if #args >= 2 then
local idx = tonumber(args[2])
if not idx then
return {
type = 'error',
message = ("Invalid argument '%s': expected test number"):format(args[2]),
}
end
if idx < 1 or idx ~= math.floor(idx) then
return { type = 'error', message = ("'%s' is not a valid test index"):format(idx) }
end
test_index = idx
end
return { type = 'action', action = 'edit', test_index = test_index }
elseif first == 'run' or first == 'panel' then
local debug = false
local test_indices = nil
local mode = 'combined'
if #args == 2 then
if args[2] == '--debug' then
debug = true
elseif args[2] == 'all' then
mode = 'individual'
else
if args[2]:find(',') then
local indices = {}
for num in args[2]:gmatch('[^,]+') do
local idx = tonumber(num)
if not idx or idx < 1 or idx ~= math.floor(idx) then
return {
type = 'error',
message = ("Invalid test index '%s' in list"):format(num),
}
end
table.insert(indices, idx)
end
if #indices == 0 then
return { type = 'error', message = 'No valid test indices provided' }
end
test_indices = indices
mode = 'individual'
else
local idx = tonumber(args[2])
if not idx then
return {
type = 'error',
message = ("Invalid argument '%s': expected test number(s), 'all', or --debug"):format(
args[2]
),
}
end
if idx < 1 or idx ~= math.floor(idx) then
return { type = 'error', message = ("'%s' is not a valid test index"):format(idx) }
end
test_indices = { idx }
mode = 'individual'
end
end
elseif #args == 3 then
if args[2] == 'all' then
mode = 'individual'
if args[3] ~= '--debug' then
return {
type = 'error',
message = ("Invalid argument '%s': expected --debug"):format(args[3]),
}
end
debug = true
elseif args[2]:find(',') then
local indices = {}
for num in args[2]:gmatch('[^,]+') do
local idx = tonumber(num)
if not idx or idx < 1 or idx ~= math.floor(idx) then
return {
type = 'error',
message = ("Invalid test index '%s' in list"):format(num),
}
end
table.insert(indices, idx)
end
if #indices == 0 then
return { type = 'error', message = 'No valid test indices provided' }
end
if args[3] ~= '--debug' then
return {
type = 'error',
message = ("Invalid argument '%s': expected --debug"):format(args[3]),
}
end
test_indices = indices
mode = 'individual'
debug = true
else
local idx = tonumber(args[2])
if not idx then
return {
type = 'error',
message = ("Invalid argument '%s': expected test number"):format(args[2]),
}
end
if idx < 1 or idx ~= math.floor(idx) then
return { type = 'error', message = ("'%s' is not a valid test index"):format(idx) }
end
if args[3] ~= '--debug' then
return {
type = 'error',
message = ("Invalid argument '%s': expected --debug"):format(args[3]),
}
end
test_indices = { idx }
mode = 'individual'
debug = true
end
elseif #args > 3 then
return {
type = 'error',
message = 'Too many arguments. Usage: :CP '
.. first
.. ' [all|test_num[,test_num...]] [--debug]',
}
end
return {
type = 'action',
action = first,
test_indices = test_indices,
debug = debug,
mode = mode,
}
else
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 = first, language = language }
end
end
if vim.tbl_contains(platforms, first) then
if #args == 1 then
return {
type = 'error',
message = 'Too few arguments - specify a contest.',
}
elseif #args == 2 then
return {
type = 'contest_setup',
platform = first,
contest = args[2],
}
elseif #args == 4 and args[3] == '--lang' then
return {
type = 'contest_setup',
platform = first,
contest = args[2],
language = args[4],
}
else
return {
type = 'error',
message = 'Invalid arguments. Usage: :CP <platform> <contest> [--lang <language>]',
}
end
end
if #args == 1 then
return {
type = 'problem_jump',
problem_id = first,
}
elseif #args == 3 and args[2] == '--lang' then
return {
type = 'problem_jump',
problem_id = first,
language = args[3],
}
end
return { type = 'error', message = 'Unknown command or no contest context.' }
end
--- Core logic for handling `:CP ...` commands
---@return nil
function M.handle_command(opts)
local cmd = parse_command(opts.fargs)
if cmd.type == 'error' then
logger.log(cmd.message, vim.log.levels.ERROR)
return
end
if cmd.type == 'restore_from_file' then
local restore = require('cp.restore')
restore.restore_from_current_file()
elseif cmd.type == 'action' then
local setup = require('cp.setup')
local ui = require('cp.ui.views')
if cmd.action == 'interact' then
ui.toggle_interactive(cmd.interactor_cmd)
elseif cmd.action == 'run' then
ui.run_io_view(cmd.test_indices, cmd.debug, cmd.mode)
elseif cmd.action == 'panel' then
ui.toggle_panel({
debug = cmd.debug,
test_index = cmd.test_indices and cmd.test_indices[1] or nil,
})
elseif cmd.action == 'next' then
setup.navigate_problem(1, cmd.language)
elseif cmd.action == 'prev' then
setup.navigate_problem(-1, cmd.language)
elseif cmd.action == 'pick' then
local picker = require('cp.commands.picker')
picker.handle_pick_action(cmd.language)
elseif cmd.action == 'edit' then
local edit = require('cp.ui.edit')
edit.toggle_edit(cmd.test_index)
end
elseif cmd.type == 'problem_jump' then
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = cmd.problem_id
if not (platform and contest_id) then
logger.log('No contest is currently active.', vim.log.levels.ERROR)
return
end
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if not (contest_data and contest_data.index_map and contest_data.index_map[problem_id]) then
logger.log(
("%s contest '%s' has no problem '%s'."):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id,
problem_id
),
vim.log.levels.ERROR
)
return
end
local setup = require('cp.setup')
setup.setup_contest(platform, contest_id, problem_id, cmd.language)
elseif cmd.type == 'cache' then
local cache_commands = require('cp.commands.cache')
cache_commands.handle_cache_command(cmd)
elseif cmd.type == 'contest_setup' then
local setup = require('cp.setup')
setup.setup_contest(cmd.platform, cmd.contest, nil, cmd.language)
return
end
end
return M

View file

@ -1,60 +0,0 @@
local M = {}
local config_module = require('cp.config')
local logger = require('cp.log')
--- Dispatch `:CP pick` to appropriate picker
---@param language? string
---@return nil
function M.handle_pick_action(language)
local config = config_module.get_config()
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
)
return
end
local picker
local picker_name = config.ui.picker
if picker_name == 'telescope' then
local ok = pcall(require, 'telescope')
if not ok then
logger.log(
'telescope.nvim is not available. Install telescope.nvim xor change your picker config.',
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)
return
end
picker = telescope_picker
elseif picker_name == 'fzf-lua' then
local ok, _ = pcall(require, 'fzf-lua')
if not ok then
logger.log(
'fzf-lua is not available. Install fzf-lua or change your picker config',
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)
return
end
picker = fzf_picker
end
picker.pick(language)
end
return M

View file

@ -1,532 +1,169 @@
-- lua/cp/config.lua ---@class LanguageConfig
---@class CpLangCommands ---@field compile? string[] Compile command template
---@field build? string[] ---@field run string[] Run command template
---@field run? string[] ---@field debug? string[] Debug command template
---@field debug? string[] ---@field executable? string Executable name
---@field version? number Language version
---@field extension string File extension
---@class CpLanguage ---@class PartialLanguageConfig
---@field extension string ---@field compile? string[] Compile command template
---@field commands CpLangCommands ---@field run? string[] Run command template
---@field debug? string[] Debug command template
---@field executable? string Executable name
---@field version? number Language version
---@field extension? string File extension
---@class CpPlatformOverrides ---@class ContestConfig
---@field extension? string ---@field cpp LanguageConfig
---@field commands? CpLangCommands ---@field python LanguageConfig
---@class CpPlatform
---@field enabled_languages string[]
---@field default_language string ---@field default_language string
---@field overrides? table<string, CpPlatformOverrides> ---@field timeout_ms number
---@class PanelConfig ---@class PartialContestConfig
---@field diff_modes string[] ---@field cpp? PartialLanguageConfig
---@field max_output_lines integer ---@field python? PartialLanguageConfig
---@field default_language? string
---@class DiffGitConfig ---@field timeout_ms? number
---@field args string[]
---@class DiffConfig
---@field git DiffGitConfig
---@class Hooks ---@class Hooks
---@field before_run? fun(state: cp.State) ---@field before_run? fun(ctx: ProblemContext)
---@field before_debug? fun(state: cp.State) ---@field before_debug? fun(ctx: ProblemContext)
---@field setup_code? fun(state: cp.State) ---@field setup_code? fun(ctx: ProblemContext)
---@field setup_io_input? fun(bufnr: integer, state: cp.State)
---@field setup_io_output? fun(bufnr: integer, state: cp.State)
---@class VerdictFormatData
---@field index integer
---@field status { text: string, highlight_group: string }
---@field time_ms number
---@field time_limit_ms number
---@field memory_mb number
---@field memory_limit_mb number
---@field exit_code integer
---@field signal string|nil
---@field time_actual_width? integer
---@field time_limit_width? integer
---@field mem_actual_width? integer
---@field mem_limit_width? integer
---@class VerdictHighlight
---@field col_start integer
---@field col_end integer
---@field group string
---@class VerdictFormatResult
---@field line string
---@field highlights? VerdictHighlight[]
---@alias VerdictFormatter fun(data: VerdictFormatData): VerdictFormatResult
---@class RunConfig
---@field width number
---@field next_test_key string|nil
---@field prev_test_key string|nil
---@field format_verdict VerdictFormatter
---@class EditConfig
---@field next_test_key string|nil
---@field prev_test_key string|nil
---@field delete_test_key string|nil
---@field add_test_key string|nil
---@field save_and_exit_key string|nil
---@class CpUI
---@field ansi boolean
---@field run RunConfig
---@field edit EditConfig
---@field panel PanelConfig
---@field diff DiffConfig
---@field picker string|nil
---@class cp.Config ---@class cp.Config
---@field languages table<string, CpLanguage> ---@field contests table<string, ContestConfig>
---@field platforms table<string, CpPlatform> ---@field snippets table[]
---@field hooks Hooks ---@field hooks Hooks
---@field debug boolean ---@field debug boolean
---@field open_url boolean ---@field scrapers table<string, boolean>
---@field scrapers string[]
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string ---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
---@field ui CpUI
---@field runtime { effective: table<string, table<string, CpLanguage>> } -- computed
---@class cp.PartialConfig: cp.Config ---@class cp.UserConfig
---@field contests? table<string, PartialContestConfig>
---@field snippets? table[]
---@field hooks? Hooks
---@field debug? boolean
---@field scrapers? table<string, boolean>
---@field filename? fun(contest: string, contest_id: string, problem_id?: string, config: cp.Config, language?: string): string
local M = {} local M = {}
local constants = require('cp.constants') local constants = require('cp.constants')
local helpers = require('cp.helpers')
local utils = require('cp.utils')
-- defaults per the new single schema
---@type cp.Config ---@type cp.Config
M.defaults = { M.defaults = {
open_url = false, contests = {},
languages = { snippets = {},
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}',
},
},
},
python = {
extension = 'py',
commands = {
run = { 'python', '{source}' },
debug = { 'python', '{source}' },
},
},
},
platforms = {
codeforces = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
overrides = {
-- example override, safe to keep empty initially
},
},
atcoder = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
codechef = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
cses = {
enabled_languages = { 'cpp', 'python' },
default_language = 'cpp',
},
},
hooks = { hooks = {
before_run = nil, before_run = nil,
before_debug = nil, before_debug = nil,
setup_code = nil, setup_code = nil,
setup_io_input = helpers.clearcol,
setup_io_output = helpers.clearcol,
}, },
debug = false, debug = false,
scrapers = constants.PLATFORMS, scrapers = constants.PLATFORMS,
filename = nil, filename = nil,
ui = {
ansi = true,
run = {
width = 0.3,
next_test_key = '<c-n>',
prev_test_key = '<c-p>',
format_verdict = helpers.default_verdict_formatter,
},
edit = {
next_test_key = ']t',
prev_test_key = '[t',
delete_test_key = 'gd',
add_test_key = 'ga',
save_and_exit_key = 'q',
},
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 = nil,
},
runtime = { effective = {} },
} }
local function is_string_list(t) ---@param user_config cp.UserConfig|nil
if type(t) ~= 'table' then
return false
end
for _, v in ipairs(t) do
if type(v) ~= 'string' then
return false
end
end
return true
end
local function has_tokens(cmd, required)
if type(cmd) ~= 'table' then
return false
end
local s = table.concat(cmd, ' ')
for _, tok in ipairs(required) do
if not s:find(vim.pesc(tok), 1, true) then
return false
end
end
return true
end
local function validate_language(id, lang)
vim.validate({
extension = { lang.extension, 'string' },
commands = { lang.commands, { 'table' } },
})
if not lang.commands.run then
error(('[cp.nvim] languages.%s.commands.run is required'):format(id))
end
if lang.commands.build ~= nil then
vim.validate({ build = { lang.commands.build, { 'table' } } })
if not has_tokens(lang.commands.build, { '{source}', '{binary}' }) then
error(('[cp.nvim] languages.%s.commands.build must include {source} and {binary}'):format(id))
end
for _, k in ipairs({ 'run', 'debug' }) do
if lang.commands[k] then
if not has_tokens(lang.commands[k], { '{binary}' }) then
error(('[cp.nvim] languages.%s.commands.%s must include {binary}'):format(id, k))
end
end
end
else
for _, k in ipairs({ 'run', 'debug' }) do
if lang.commands[k] then
if not has_tokens(lang.commands[k], { '{source}' }) then
error(('[cp.nvim] languages.%s.commands.%s must include {source}'):format(id, k))
end
end
end
end
end
local function merge_lang(base, ov)
if not ov then
return base
end
local out = vim.deepcopy(base)
if ov.extension then
out.extension = ov.extension
end
if ov.commands then
out.commands = vim.tbl_deep_extend('force', out.commands or {}, ov.commands or {})
end
return out
end
---@param cfg cp.Config
local function build_runtime(cfg)
cfg.runtime = cfg.runtime or { effective = {} }
for plat, p in pairs(cfg.platforms) do
vim.validate({
enabled_languages = { p.enabled_languages, is_string_list, 'string[]' },
default_language = { p.default_language, 'string' },
})
for _, lid in ipairs(p.enabled_languages) do
if not cfg.languages[lid] then
error(("[cp.nvim] platform %s references unknown language '%s'"):format(plat, lid))
end
end
if not vim.tbl_contains(p.enabled_languages, p.default_language) then
error(
("[cp.nvim] platform %s default_language '%s' not in enabled_languages"):format(
plat,
p.default_language
)
)
end
cfg.runtime.effective[plat] = {}
for _, lid in ipairs(p.enabled_languages) do
local base = cfg.languages[lid]
validate_language(lid, base)
local eff = merge_lang(base, p.overrides and p.overrides[lid] or nil)
validate_language(lid, eff)
cfg.runtime.effective[plat][lid] = eff
end
end
end
---@param user_config cp.PartialConfig|nil
---@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({
local defaults = vim.deepcopy(M.defaults) user_config = { user_config, { 'table', 'nil' }, true },
if user_config and user_config.platforms then })
for plat in pairs(defaults.platforms) do
if not user_config.platforms[plat] then if user_config then
defaults.platforms[plat] = nil vim.validate({
contests = { user_config.contests, { 'table', 'nil' }, true },
snippets = { user_config.snippets, { 'table', 'nil' }, true },
hooks = { user_config.hooks, { 'table', 'nil' }, true },
debug = { user_config.debug, { 'boolean', 'nil' }, true },
scrapers = { user_config.scrapers, { 'table', 'nil' }, true },
filename = { user_config.filename, { 'function', 'nil' }, true },
})
if user_config.hooks then
vim.validate({
before_run = {
user_config.hooks.before_run,
{ 'function', 'nil' },
true,
},
before_debug = {
user_config.hooks.before_debug,
{ 'function', 'nil' },
true,
},
setup_code = {
user_config.hooks.setup_code,
{ 'function', 'nil' },
true,
},
})
end
if user_config.contests then
for contest_name, contest_config in pairs(user_config.contests) do
for lang_name, lang_config in pairs(contest_config) do
if type(lang_config) == 'table' and lang_config.extension then
if
not vim.tbl_contains(
vim.tbl_keys(constants.filetype_to_language),
lang_config.extension
)
then
error(
("Invalid extension '%s' for language '%s' in contest '%s'. Valid extensions: %s"):format(
lang_config.extension,
lang_name,
contest_name,
table.concat(vim.tbl_keys(constants.filetype_to_language), ', ')
)
)
end
end
end
end
end
if user_config.scrapers then
for contest_name, enabled in pairs(user_config.scrapers) do
if not vim.tbl_contains(constants.PLATFORMS, contest_name) then
error(
("Invalid contest '%s' in scrapers config. Valid contests: %s"):format(
contest_name,
table.concat(constants.PLATFORMS, ', ')
)
)
end
if type(enabled) ~= 'boolean' then
error(
("Scraper setting for '%s' must be boolean, got %s"):format(contest_name, type(enabled))
)
end
end end
end end
end end
local cfg = vim.tbl_deep_extend('force', defaults, user_config or {})
if not next(cfg.languages) then local config = vim.tbl_deep_extend('force', M.defaults, user_config or {})
error('[cp.nvim] At least one language must be configured') return config
end
if not next(cfg.platforms) then
error('[cp.nvim] At least one platform must be configured')
end
vim.validate({
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,
function(v)
if type(v) ~= 'table' then
return false
end
for _, s in ipairs(v) do
if not vim.tbl_contains(constants.PLATFORMS, s) then
return false
end
end
return true
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 },
})
local layouts = require('cp.ui.layouts')
vim.validate({
ansi = { cfg.ui.ansi, 'boolean' },
diff_modes = {
cfg.ui.panel.diff_modes,
function(v)
if type(v) ~= 'table' then
return false
end
for _, mode in ipairs(v) do
if not layouts.DIFF_MODES[mode] then
return false
end
end
return true
end,
('one of {%s}'):format(table.concat(vim.tbl_keys(layouts.DIFF_MODES), ',')),
},
max_output_lines = {
cfg.ui.panel.max_output_lines,
function(v)
return type(v) == 'number' and v > 0 and v == math.floor(v)
end,
'positive integer',
},
git = { cfg.ui.diff.git, { 'table' } },
git_args = { cfg.ui.diff.git.args, is_string_list, 'string[]' },
width = {
cfg.ui.run.width,
function(v)
return type(v) == 'number' and v > 0 and v <= 1
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',
},
edit_next_test_key = {
cfg.ui.edit.next_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
edit_prev_test_key = {
cfg.ui.edit.prev_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
delete_test_key = {
cfg.ui.edit.delete_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
add_test_key = {
cfg.ui.edit.add_test_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
save_and_exit_key = {
cfg.ui.edit.save_and_exit_key,
function(v)
return v == nil or (type(v) == 'string' and #v > 0)
end,
'nil or non-empty string',
},
picker = {
cfg.ui.picker,
function(v)
return v == nil or v == 'telescope' or v == 'fzf-lua'
end,
"nil, 'telescope', or 'fzf-lua'",
},
})
for id, lang in pairs(cfg.languages) do
validate_language(id, lang)
end
build_runtime(cfg)
local ok, err = utils.check_required_runtime()
if not ok then
error('[cp.nvim] ' .. err)
end
return cfg
end
local current_config = nil
function M.set_current_config(config)
current_config = config
end
function M.get_config()
return current_config or M.defaults
end
---Validate and get effective language config for a platform
---@param platform_id string
---@param language_id string
---@return { valid: boolean, effective?: CpLanguage, extension?: string, error?: string }
function M.get_language_for_platform(platform_id, language_id)
local cfg = M.get_config()
if not cfg.platforms[platform_id] then
return { valid = false, error = string.format("Unknown platform '%s'", platform_id) }
end
local platform = cfg.platforms[platform_id]
if not cfg.languages[language_id] then
local available = table.concat(platform.enabled_languages, ', ')
return {
valid = false,
error = string.format("Unknown language '%s'. Available: [%s]", language_id, available),
}
end
if not vim.tbl_contains(platform.enabled_languages, language_id) then
local available = table.concat(platform.enabled_languages, ', ')
return {
valid = false,
error = string.format(
"Language '%s' not enabled for %s. Available: [%s]",
language_id,
platform_id,
available
),
}
end
local platform_effective = cfg.runtime.effective[platform_id]
if not platform_effective then
return {
valid = false,
error = string.format(
'No runtime config for platform %s (plugin not initialized)',
platform_id
),
}
end
local effective = platform_effective[language_id]
if not effective then
return {
valid = false,
error = string.format('No effective config for %s/%s', platform_id, language_id),
}
end
return {
valid = true,
effective = effective,
extension = effective.extension,
}
end end
---@param contest_id string ---@param contest_id string
---@param problem_id? string ---@param problem_id? string
---@return string ---@return string
local function default_filename(contest_id, problem_id) local function default_filename(contest_id, problem_id)
vim.validate({
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
})
if problem_id then if problem_id then
return (contest_id .. problem_id):lower() return (contest_id .. problem_id):lower()
else
return contest_id:lower()
end end
return contest_id:lower()
end end
M.default_filename = default_filename M.default_filename = default_filename
return M return M

View file

@ -1,22 +1,18 @@
local M = {} local M = {}
M.PLATFORMS = { 'atcoder', 'codechef', 'codeforces', 'cses' } M.PLATFORMS = { 'atcoder', 'codeforces', 'cses' }
M.ACTIONS = { 'run', 'panel', 'next', 'prev', 'pick', 'cache', 'interact', 'edit' } M.ACTIONS = { 'test', 'next', 'prev' }
M.PLATFORM_DISPLAY_NAMES = {
atcoder = 'AtCoder',
codechef = 'CodeChef',
codeforces = 'CodeForces',
cses = 'CSES',
}
M.CPP = 'cpp' M.CPP = 'cpp'
M.PYTHON = 'python' M.PYTHON = 'python'
---@type table<string, string> ---@type table<string, string>
M.filetype_to_language = { M.filetype_to_language = {
python = M.PYTHON, cc = M.CPP,
cxx = M.CPP,
cpp = M.CPP, cpp = M.CPP,
py = M.PYTHON,
py3 = M.PYTHON,
} }
---@type table<string, string> ---@type table<string, string>
@ -25,12 +21,6 @@ M.canonical_filetypes = {
[M.PYTHON] = 'python', [M.PYTHON] = 'python',
} }
---@type table<string, string>
M.canonical_filetype_to_extension = {
[M.CPP] = 'cc',
[M.PYTHON] = 'py',
}
---@type table<number, string> ---@type table<number, string>
M.signal_codes = { M.signal_codes = {
[128] = 'SIGILL', [128] = 'SIGILL',

296
lua/cp/execute.lua Normal file
View file

@ -0,0 +1,296 @@
---@class ExecuteResult
---@field stdout string
---@field stderr string
---@field code integer
---@field time_ms number
---@field timed_out boolean
local M = {}
local logger = require('cp.log')
local constants = require('cp.constants')
local filetype_to_language = constants.filetype_to_language
---@param source_file string
---@param contest_config table
---@return string
local function get_language_from_file(source_file, contest_config)
vim.validate({
source_file = { source_file, 'string' },
contest_config = { contest_config, 'table' },
})
local extension = vim.fn.fnamemodify(source_file, ':e')
local language = filetype_to_language[extension] or contest_config.default_language
logger.log(('detected language: %s (extension: %s)'):format(language, extension))
return language
end
---@param cmd_template string[]
---@param substitutions table<string, string>
---@return string[]
local function substitute_template(cmd_template, substitutions)
vim.validate({
cmd_template = { cmd_template, 'table' },
substitutions = { substitutions, 'table' },
})
local result = {}
for _, arg in ipairs(cmd_template) do
local substituted = arg
for key, value in pairs(substitutions) do
substituted = substituted:gsub('{' .. key .. '}', value)
end
table.insert(result, substituted)
end
return result
end
---@param cmd_template string[]
---@param executable? string
---@param substitutions table<string, string>
---@return string[]
local function build_command(cmd_template, executable, substitutions)
vim.validate({
cmd_template = { cmd_template, 'table' },
executable = { executable, { 'string', 'nil' }, true },
substitutions = { substitutions, 'table' },
})
local cmd = substitute_template(cmd_template, substitutions)
if executable then
table.insert(cmd, 1, executable)
end
return cmd
end
local function ensure_directories()
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
end
---@param language_config table
---@param substitutions table<string, string>
---@return {code: integer, stderr: string}
function M.compile_generic(language_config, substitutions)
vim.validate({
language_config = { language_config, 'table' },
substitutions = { substitutions, 'table' },
})
if not language_config.compile then
logger.log('no compilation step required')
return { code = 0, stderr = '' }
end
local compile_cmd = substitute_template(language_config.compile, substitutions)
logger.log(('compiling: %s'):format(table.concat(compile_cmd, ' ')))
local start_time = vim.uv.hrtime()
local result = vim.system(compile_cmd, { text = true }):wait()
local compile_time = (vim.uv.hrtime() - start_time) / 1000000
if result.code == 0 then
logger.log(('compilation successful (%.1fms)'):format(compile_time))
else
logger.log(
('compilation failed (%.1fms): %s'):format(compile_time, result.stderr),
vim.log.levels.WARN
)
end
return result
end
---@param cmd string[]
---@param input_data string
---@param timeout_ms integer
---@return ExecuteResult
local function execute_command(cmd, input_data, timeout_ms)
vim.validate({
cmd = { cmd, 'table' },
input_data = { input_data, 'string' },
timeout_ms = { timeout_ms, 'number' },
})
logger.log(('executing: %s'):format(table.concat(cmd, ' ')))
local start_time = vim.uv.hrtime()
local result = vim
.system(cmd, {
stdin = input_data,
timeout = timeout_ms,
text = true,
})
:wait()
local end_time = vim.uv.hrtime()
local execution_time = (end_time - start_time) / 1000000
local actual_code = result.code or 0
if result.code == 124 then
logger.log(('execution timed out after %.1fms'):format(execution_time), vim.log.levels.WARN)
elseif actual_code ~= 0 then
logger.log(
('execution failed (exit code %d, %.1fms)'):format(actual_code, execution_time),
vim.log.levels.WARN
)
else
logger.log(('execution successful (%.1fms)'):format(execution_time))
end
return {
stdout = result.stdout or '',
stderr = result.stderr or '',
code = actual_code,
time_ms = execution_time,
timed_out = result.code == 124,
}
end
---@param exec_result ExecuteResult
---@param expected_file string
---@param is_debug boolean
---@return string
local function format_output(exec_result, expected_file, is_debug)
vim.validate({
exec_result = { exec_result, 'table' },
expected_file = { expected_file, 'string' },
is_debug = { is_debug, 'boolean' },
})
local output_lines = { exec_result.stdout }
local metadata_lines = {}
if exec_result.timed_out then
table.insert(metadata_lines, '[code]: 124 (TIMEOUT)')
elseif exec_result.code >= 128 then
local signal_name = constants.signal_codes[exec_result.code] or 'SIGNAL'
table.insert(metadata_lines, ('[code]: %d (%s)'):format(exec_result.code, signal_name))
else
table.insert(metadata_lines, ('[code]: %d'):format(exec_result.code))
end
table.insert(metadata_lines, ('[time]: %.2f ms'):format(exec_result.time_ms))
table.insert(metadata_lines, ('[debug]: %s'):format(is_debug and 'true' or 'false'))
if vim.fn.filereadable(expected_file) == 1 and exec_result.code == 0 then
local expected_content = vim.fn.readfile(expected_file)
local actual_lines = vim.split(exec_result.stdout, '\n')
while #actual_lines > 0 and actual_lines[#actual_lines] == '' do
table.remove(actual_lines)
end
local ok = #actual_lines == #expected_content
if ok then
for i, line in ipairs(actual_lines) do
if line ~= expected_content[i] then
ok = false
break
end
end
end
table.insert(metadata_lines, ('[ok]: %s'):format(ok and 'true' or 'false'))
end
return table.concat(output_lines, '') .. '\n' .. table.concat(metadata_lines, '\n')
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@param is_debug? boolean
---@return boolean success
function M.compile_problem(ctx, contest_config, is_debug)
vim.validate({
ctx = { ctx, 'table' },
contest_config = { contest_config, 'table' },
})
local language = get_language_from_file(ctx.source_file, contest_config)
local language_config = contest_config[language]
if not language_config then
logger.log('No configuration for language: ' .. language, vim.log.levels.ERROR)
return false
end
local substitutions = {
source = ctx.source_file,
binary = ctx.binary_file,
version = tostring(language_config.version),
}
local compile_cmd = (is_debug and language_config.debug) and language_config.debug
or language_config.compile
if compile_cmd then
language_config.compile = compile_cmd
local compile_result = M.compile_generic(language_config, substitutions)
if compile_result.code ~= 0 then
logger.log(
'compilation failed: ' .. (compile_result.stderr or 'unknown error'),
vim.log.levels.ERROR
)
return false
end
logger.log(('compilation successful (%s)'):format(is_debug and 'debug mode' or 'test mode'))
end
return true
end
function M.run_problem(ctx, contest_config, is_debug)
vim.validate({
ctx = { ctx, 'table' },
contest_config = { contest_config, 'table' },
is_debug = { is_debug, 'boolean' },
})
ensure_directories()
local language = get_language_from_file(ctx.source_file, contest_config)
local language_config = contest_config[language]
if not language_config then
vim.fn.writefile({ 'Error: No configuration for language: ' .. language }, ctx.output_file)
return
end
local substitutions = {
source = ctx.source_file,
binary = ctx.binary_file,
version = tostring(language_config.version),
}
local compile_cmd = is_debug and language_config.debug or language_config.compile
if compile_cmd then
local compile_result = M.compile_generic(language_config, substitutions)
if compile_result.code ~= 0 then
vim.fn.writefile({ compile_result.stderr }, ctx.output_file)
return
end
end
local input_data = ''
if vim.fn.filereadable(ctx.input_file) == 1 then
input_data = table.concat(vim.fn.readfile(ctx.input_file), '\n') .. '\n'
end
local run_cmd = build_command(language_config.run, language_config.executable, substitutions)
local exec_result = execute_command(run_cmd, input_data, contest_config.timeout_ms)
local formatted_output = format_output(exec_result, ctx.expected_file, is_debug)
local output_buf = vim.fn.bufnr(ctx.output_file)
if output_buf ~= -1 then
vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, vim.split(formatted_output, '\n'))
vim.api.nvim_buf_call(output_buf, function()
vim.cmd.write()
end)
else
vim.fn.writefile(vim.split(formatted_output, '\n'), ctx.output_file)
end
end
return M

View file

@ -1,77 +1,95 @@
local M = {} local M = {}
local utils = require('cp.utils') local function check_nvim_version()
local function check()
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
vim.health.error('cp.nvim requires Neovim 0.10.0+') vim.health.error('cp.nvim requires Neovim 0.10.0+')
end end
end
local uname = vim.loop.os_uname() local function check_uv()
if uname.sysname == 'Windows_NT' then if vim.fn.executable('uv') == 1 then
vim.health.error('Windows is not supported') vim.health.ok('uv executable found')
end
if utils.is_nix_build() then local result = vim.system({ 'uv', '--version' }, { text = true }):wait()
local source = utils.is_nix_discovered() and 'runtime discovery' or 'flake install' if result.code == 0 then
vim.health.ok('Nix Python environment detected (' .. source .. ')') vim.health.info('uv version: ' .. result.stdout:gsub('\n', ''))
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 end
else else
if vim.fn.executable('uv') == 1 then vim.health.warn('uv not found - install from https://docs.astral.sh/uv/ for problem scraping')
vim.health.ok('uv executable found') end
local r = vim.system({ 'uv', '--version' }, { text = true }):wait() end
if r.code == 0 then
vim.health.info('uv version: ' .. r.stdout:gsub('\n', '')) local function check_python_env()
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h')
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.warn('Python virtual environment not set up - run :CP command to initialize')
end
end
local function check_scrapers()
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
plugin_path = vim.fn.fnamemodify(plugin_path, ':h:h:h')
local scrapers = { 'atcoder.py', 'codeforces.py', 'cses.py' }
for _, scraper in ipairs(scrapers) do
local scraper_path = plugin_path .. '/scrapers/' .. scraper
if vim.fn.filereadable(scraper_path) == 1 then
vim.health.ok('Scraper found: ' .. scraper)
else
vim.health.error('Missing scraper: ' .. scraper)
end
end
end
local function check_luasnip()
local has_luasnip, luasnip = pcall(require, 'luasnip')
if has_luasnip then
vim.health.ok('LuaSnip integration available')
local snippet_count = #luasnip.get_snippets('all')
vim.health.info('LuaSnip snippets loaded: ' .. snippet_count)
else
vim.health.info('LuaSnip not available - template expansion will be limited')
end
end
local function check_config()
vim.health.ok('Plugin ready')
local cp = require('cp')
local context = cp.get_current_context()
if context.platform then
local info = context.platform
if context.contest_id then
info = info .. ' ' .. context.contest_id
if context.problem_id then
info = info .. ' ' .. context.problem_id
end end
else
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end end
vim.health.info('Current context: ' .. info)
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
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()
if time_cap.ok then
vim.health.ok('GNU time found: ' .. time_cap.path)
else else
vim.health.error('GNU time not found: ' .. (time_cap.reason or '')) vim.health.info('No contest context set')
end
local timeout_cap = utils.timeout_capability()
if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else
vim.health.error('GNU timeout not found: ' .. (timeout_cap.reason or ''))
end end
end end
function M.check() function M.check()
local version = require('cp.version') local version = require('cp.version')
vim.health.start('cp.nvim health check ~') vim.health.start('cp.nvim health check')
vim.health.info('Version: ' .. version.version) vim.health.info('Version: ' .. version.version)
check() check_nvim_version()
check_uv()
check_python_env()
check_scrapers()
check_luasnip()
check_config()
end end
return M return M

View file

@ -1,101 +0,0 @@
local M = {}
---@param bufnr integer
function M.clearcol(bufnr)
for _, win in ipairs(vim.fn.win_findbuf(bufnr)) do
vim.wo[win].signcolumn = 'no'
vim.wo[win].statuscolumn = ''
vim.wo[win].number = false
vim.wo[win].relativenumber = false
end
end
---Pad text on the right (left-align text within width)
---@param text string
---@param width integer
---@return string
function M.pad_right(text, width)
local pad = width - #text
if pad <= 0 then
return text
end
return text .. string.rep(' ', pad)
end
---Pad text on the left (right-align text within width)
---@param text string
---@param width integer
---@return string
function M.pad_left(text, width)
local pad = width - #text
if pad <= 0 then
return text
end
return string.rep(' ', pad) .. text
end
---Center text within width
---@param text string
---@param width integer
---@return string
function M.center(text, width)
local pad = width - #text
if pad <= 0 then
return text
end
local left = math.ceil(pad / 2)
return string.rep(' ', left) .. text .. string.rep(' ', pad - left)
end
---Default verdict formatter for I/O view
---@param data VerdictFormatData
---@return VerdictFormatResult
function M.default_verdict_formatter(data)
local time_actual = string.format('%.2f', data.time_ms)
local time_limit = tostring(data.time_limit_ms)
local mem_actual = string.format('%.0f', data.memory_mb)
local mem_limit = string.format('%.0f', data.memory_limit_mb)
local exit_str = data.signal and string.format('%d (%s)', data.exit_code, data.signal)
or tostring(data.exit_code)
local time_actual_w = data.time_actual_width or 6
local time_limit_w = data.time_limit_width or 4
local mem_actual_w = data.mem_actual_width or 3
local mem_limit_w = data.mem_limit_width or 3
local test_num_part = 'Test ' .. data.index .. ':'
local status_part = M.pad_right(data.status.text, 3)
local time_part = M.pad_left(time_actual, time_actual_w)
.. '/'
.. M.pad_left(time_limit, time_limit_w)
.. ' ms'
local mem_part = M.pad_left(mem_actual, mem_actual_w)
.. '/'
.. M.pad_left(mem_limit, mem_limit_w)
.. ' MB'
local exit_part = 'exit: ' .. exit_str
local line = test_num_part
.. ' '
.. status_part
.. ' | '
.. time_part
.. ' | '
.. mem_part
.. ' | '
.. exit_part
local highlights = {}
local status_pos = line:find(data.status.text, 1, true)
if status_pos then
table.insert(highlights, {
col_start = status_pos - 1,
col_end = status_pos - 1 + #data.status.text,
group = data.status.highlight_group,
})
end
return { line = line, highlights = highlights }
end
return M

View file

@ -1,54 +1,701 @@
local M = {} local M = {}
local cache = require('cp.cache')
local config_module = require('cp.config') local config_module = require('cp.config')
local helpers = require('cp.helpers')
local logger = require('cp.log') local logger = require('cp.log')
local problem = require('cp.problem')
local scrape = require('cp.scrape')
local snippets = require('cp.snippets')
M.helpers = helpers if not vim.fn.has('nvim-0.10.0') then
vim.notify('[cp.nvim]: requires nvim-0.10.0+', vim.log.levels.ERROR)
if vim.fn.has('nvim-0.10.0') == 0 then
logger.log('Requires nvim-0.10.0+', vim.log.levels.ERROR)
return {} return {}
end end
local initialized = false local user_config = {}
local config = config_module.setup(user_config)
logger.set_config(config)
local snippets_initialized = false
local function ensure_initialized() local state = {
if initialized then platform = nil,
return true contest_id = nil,
end problem_id = nil,
local user_config = vim.g.cp or {} saved_layout = nil,
local ok, result = pcall(config_module.setup, user_config) saved_session = nil,
if not ok then test_cases = nil,
local msg = tostring(result):gsub('^.+:%d+: ', '') test_states = {},
vim.notify(msg, vim.log.levels.ERROR) test_panel_active = false,
}
local constants = require('cp.constants')
local platforms = constants.PLATFORMS
local actions = constants.ACTIONS
local function set_platform(platform)
if not vim.tbl_contains(platforms, platform) then
logger.log(
('unknown platform. Available: [%s]'):format(table.concat(platforms, ', ')),
vim.log.levels.ERROR
)
return false return false
end end
config_module.set_current_config(result)
initialized = true state.platform = platform
vim.fn.mkdir('build', 'p')
vim.fn.mkdir('io', 'p')
return true return true
end end
---@return nil ---@param contest_id string
function M.handle_command(opts) ---@param problem_id? string
if not ensure_initialized() then ---@param language? string
local function setup_problem(contest_id, problem_id, language)
if not state.platform then
logger.log('no platform set. run :CP <platform> <contest> first', vim.log.levels.ERROR)
return return
end end
local commands = require('cp.commands')
commands.handle_command(opts) local problem_name = state.platform == 'cses' and contest_id or (contest_id .. (problem_id or ''))
logger.log(('setting up problem: %s'):format(problem_name))
local ctx = problem.create_context(state.platform, contest_id, problem_id, config, language)
if vim.tbl_contains(config.scrapers, state.platform) then
local metadata_result = scrape.scrape_contest_metadata(state.platform, contest_id)
if not metadata_result.success then
logger.log(
'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'),
vim.log.levels.WARN
)
end
end
local cached_test_cases = cache.get_test_cases(state.platform, contest_id, problem_id)
if cached_test_cases then
state.test_cases = cached_test_cases
end
if vim.tbl_contains(config.scrapers, state.platform) then
local scrape_result = scrape.scrape_problem(ctx)
if not scrape_result.success then
logger.log(
'scraping failed: ' .. (scrape_result.error or 'unknown error'),
vim.log.levels.ERROR
)
return
end
local test_count = scrape_result.test_count or 0
logger.log(('scraped %d test case(s) for %s'):format(test_count, scrape_result.problem_id))
state.test_cases = scrape_result.test_cases
if scrape_result.test_cases then
cache.set_test_cases(state.platform, contest_id, problem_id, scrape_result.test_cases)
end
else
logger.log(('scraping disabled for %s'):format(state.platform))
state.test_cases = nil
end
vim.cmd('silent only')
state.contest_id = contest_id
state.problem_id = problem_id
vim.cmd.e(ctx.source_file)
local source_buf = vim.api.nvim_get_current_buf()
if vim.api.nvim_buf_get_lines(source_buf, 0, -1, true)[1] == '' then
local has_luasnip, luasnip = pcall(require, 'luasnip')
if has_luasnip then
local filetype = vim.api.nvim_get_option_value('filetype', { buf = source_buf })
local language_name = constants.filetype_to_language[filetype]
local canonical_language = constants.canonical_filetypes[language_name] or language_name
local prefixed_trigger = ('cp.nvim/%s.%s'):format(state.platform, canonical_language)
vim.api.nvim_buf_set_lines(0, 0, -1, false, { prefixed_trigger })
vim.api.nvim_win_set_cursor(0, { 1, #prefixed_trigger })
vim.cmd.startinsert({ bang = true })
vim.schedule(function()
if luasnip.expandable() then
luasnip.expand()
else
vim.api.nvim_buf_set_lines(0, 0, 1, false, { '' })
vim.api.nvim_win_set_cursor(0, { 1, 0 })
end
vim.cmd.stopinsert()
end)
else
vim.api.nvim_input(('i%s<c-space><esc>'):format(state.platform))
end
end
if config.hooks and config.hooks.setup_code then
config.hooks.setup_code(ctx)
end
logger.log(('switched to problem %s'):format(ctx.problem_name))
end
local function get_current_problem()
local filename = vim.fn.expand('%:t:r')
if filename == '' then
logger.log('no file open', vim.log.levels.ERROR)
return nil
end
return filename
end
local function toggle_test_panel(is_debug)
if state.test_panel_active then
if state.saved_session then
vim.cmd(('source %s'):format(state.saved_session))
vim.fn.delete(state.saved_session)
state.saved_session = nil
end
state.test_panel_active = false
logger.log('test panel closed')
return
end
if not state.platform then
logger.log(
'No contest configured. Use :CP <platform> <contest> <problem> to set up first.',
vim.log.levels.ERROR
)
return
end
local problem_id = get_current_problem()
if not problem_id then
return
end
local ctx = problem.create_context(state.platform, state.contest_id, state.problem_id, config)
local test_module = require('cp.test')
if not test_module.load_test_cases(ctx, state) then
logger.log('no test cases found', vim.log.levels.WARN)
return
end
state.saved_session = vim.fn.tempname()
vim.cmd(('mksession! %s'):format(state.saved_session))
vim.cmd('silent only')
local tab_buf = vim.api.nvim_create_buf(false, true)
local expected_buf = vim.api.nvim_create_buf(false, true)
local actual_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = tab_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = expected_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = actual_buf })
local main_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(main_win, tab_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = tab_buf })
vim.cmd.split()
vim.api.nvim_win_set_buf(0, actual_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = actual_buf })
vim.cmd.vsplit()
vim.api.nvim_win_set_buf(0, expected_buf)
vim.api.nvim_set_option_value('filetype', 'cptest', { buf = expected_buf })
local expected_win = vim.fn.bufwinid(expected_buf)
local actual_win = vim.fn.bufwinid(actual_buf)
local test_windows = {
tab_win = main_win,
actual_win = actual_win,
expected_win = expected_win,
}
local test_buffers = {
tab_buf = tab_buf,
expected_buf = expected_buf,
actual_buf = actual_buf,
}
local function render_test_tabs()
local test_state = test_module.get_test_panel_state()
local tab_lines = {}
local max_status_width = 0
local max_code_width = 0
local max_time_width = 0
for _, test_case in ipairs(test_state.test_cases) do
local status_text = test_case.status == 'pending' and '' or string.upper(test_case.status)
max_status_width = math.max(max_status_width, #status_text)
if test_case.code then
max_code_width = math.max(max_code_width, #tostring(test_case.code))
end
if test_case.time_ms then
local time_text = string.format('%.0fms', test_case.time_ms)
max_time_width = math.max(max_time_width, #time_text)
end
end
for i, test_case in ipairs(test_state.test_cases) do
local prefix = i == test_state.current_index and '> ' or ' '
local tab = string.format('%s%d.', prefix, i)
if test_case.ok ~= nil then
tab = tab .. string.format(' [ok:%-5s]', tostring(test_case.ok))
end
if test_case.code then
tab = tab .. string.format(' [code:%-' .. max_code_width .. 's]', tostring(test_case.code))
end
if test_case.time_ms then
local time_text = string.format('%.0fms', test_case.time_ms)
tab = tab .. string.format(' [time:%-' .. max_time_width .. 's]', time_text)
end
if test_case.signal then
tab = tab .. string.format(' [%s]', test_case.signal)
end
table.insert(tab_lines, tab)
end
local current_test = test_state.test_cases[test_state.current_index]
if current_test then
table.insert(tab_lines, '')
table.insert(tab_lines, 'Input:')
for _, line in ipairs(vim.split(current_test.input, '\n', { plain = true, trimempty = true })) do
table.insert(tab_lines, line)
end
end
return tab_lines
end
local function update_expected_pane()
local test_state = test_module.get_test_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
if not current_test then
return
end
local expected_text = current_test.expected
local expected_lines = vim.split(expected_text, '\n', { plain = true, trimempty = true })
vim.api.nvim_buf_set_lines(test_buffers.expected_buf, 0, -1, false, expected_lines)
if vim.fn.has('nvim-0.8.0') == 1 then
vim.api.nvim_set_option_value('winbar', 'Expected', { win = test_windows.expected_win })
end
end
local function update_actual_pane()
local test_state = test_module.get_test_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
if not current_test then
return
end
local actual_lines = {}
local enable_diff = false
if current_test.actual then
actual_lines = vim.split(current_test.actual, '\n', { plain = true, trimempty = true })
enable_diff = current_test.status == 'fail'
else
actual_lines = { '(not run yet)' }
end
vim.api.nvim_buf_set_lines(test_buffers.actual_buf, 0, -1, false, actual_lines)
if vim.fn.has('nvim-0.8.0') == 1 then
vim.api.nvim_set_option_value('winbar', 'Actual', { win = test_windows.actual_win })
end
vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.expected_win })
vim.api.nvim_set_option_value('diff', enable_diff, { win = test_windows.actual_win })
if enable_diff then
vim.api.nvim_win_call(test_windows.expected_win, function()
vim.cmd.diffthis()
end)
vim.api.nvim_win_call(test_windows.actual_win, function()
vim.cmd.diffthis()
end)
end
end
local function refresh_test_panel()
if not test_buffers.tab_buf or not vim.api.nvim_buf_is_valid(test_buffers.tab_buf) then
return
end
local tab_lines = render_test_tabs()
vim.api.nvim_buf_set_lines(test_buffers.tab_buf, 0, -1, false, tab_lines)
update_expected_pane()
update_actual_pane()
end
local function navigate_test_case(delta)
local test_state = test_module.get_test_panel_state()
if #test_state.test_cases == 0 then
return
end
test_state.current_index = test_state.current_index + delta
if test_state.current_index < 1 then
test_state.current_index = #test_state.test_cases
elseif test_state.current_index > #test_state.test_cases then
test_state.current_index = 1
end
refresh_test_panel()
end
vim.keymap.set('n', '<c-n>', function()
navigate_test_case(1)
end, { buffer = test_buffers.tab_buf, silent = true })
vim.keymap.set('n', '<c-p>', function()
navigate_test_case(-1)
end, { buffer = test_buffers.tab_buf, silent = true })
for _, buf in pairs(test_buffers) do
vim.keymap.set('n', 'q', function()
toggle_test_panel()
end, { buffer = buf, silent = true })
end
if is_debug and config.hooks and config.hooks.before_debug then
config.hooks.before_debug(ctx)
end
local execute_module = require('cp.execute')
local contest_config = config.contests[state.platform]
if execute_module.compile_problem(ctx, contest_config, is_debug) then
test_module.run_all_test_cases(ctx, contest_config)
end
refresh_test_panel()
vim.api.nvim_set_current_win(test_windows.tab_win)
state.test_panel_active = true
state.test_buffers = test_buffers
state.test_windows = test_windows
local test_state = test_module.get_test_panel_state()
logger.log(string.format('test panel opened (%d test cases)', #test_state.test_cases))
end
---@param delta number 1 for next, -1 for prev
---@param language? string
local function navigate_problem(delta, language)
if not state.platform or not state.contest_id then
logger.log('no contest set. run :CP <platform> <contest> first', vim.log.levels.ERROR)
return
end
cache.load()
local contest_data = cache.get_contest_data(state.platform, state.contest_id)
if not contest_data or not contest_data.problems then
logger.log(
'no contest metadata found. set up a problem first to cache contest data',
vim.log.levels.ERROR
)
return
end
local problems = contest_data.problems
local current_problem_id
if state.platform == 'cses' then
current_problem_id = state.contest_id
else
current_problem_id = state.problem_id
end
if not current_problem_id then
logger.log('no current problem set', vim.log.levels.ERROR)
return
end
local current_index = nil
for i, prob in ipairs(problems) do
if prob.id == current_problem_id then
current_index = i
break
end
end
if not current_index then
logger.log('current problem not found in contest', vim.log.levels.ERROR)
return
end
local new_index = current_index + delta
if new_index < 1 or new_index > #problems then
local direction = delta > 0 and 'next' or 'previous'
logger.log(('no %s problem available'):format(direction), vim.log.levels.INFO)
return
end
local new_problem = problems[new_index]
if state.platform == 'cses' then
setup_problem(new_problem.id, nil, language)
else
setup_problem(state.contest_id, new_problem.id, language)
end
end
local function parse_command(args)
if #args == 0 then
return {
type = 'error',
message = 'Usage: :CP <platform> <contest> [problem] [--lang=<language>] | :CP <action> | :CP <problem>',
}
end
local language = nil
local debug = false
for i, arg in ipairs(args) do
local lang_match = arg:match('^--lang=(.+)$')
if lang_match then
language = lang_match
elseif arg == '--lang' then
if i + 1 <= #args then
language = args[i + 1]
else
return { type = 'error', message = '--lang requires a value' }
end
elseif arg == '--debug' then
debug = true
end
end
local filtered_args = vim.tbl_filter(function(arg)
return not (arg:match('^--lang') or arg == language or arg == '--debug')
end, args)
local first = filtered_args[1]
if vim.tbl_contains(actions, first) then
return { type = 'action', action = first, language = language, debug = debug }
end
if vim.tbl_contains(platforms, first) then
if #filtered_args == 1 then
return {
type = 'platform_only',
platform = first,
language = language,
}
elseif #filtered_args == 2 then
if first == 'cses' then
return {
type = 'cses_problem',
platform = first,
problem = filtered_args[2],
language = language,
}
else
return {
type = 'contest_setup',
platform = first,
contest = filtered_args[2],
language = language,
}
end
elseif #filtered_args == 3 then
return {
type = 'full_setup',
platform = first,
contest = filtered_args[2],
problem = filtered_args[3],
language = language,
}
else
return { type = 'error', message = 'Too many arguments' }
end
end
if state.platform and state.contest_id then
cache.load()
local contest_data = cache.get_contest_data(state.platform, state.contest_id)
if contest_data and contest_data.problems then
local problem_ids = vim.tbl_map(function(prob)
return prob.id
end, contest_data.problems)
if vim.tbl_contains(problem_ids, first) then
return { type = 'problem_switch', problem = first, language = language }
end
end
return {
type = 'error',
message = ("invalid subcommand '%s'"):format(first),
}
end
return { type = 'error', message = 'Unknown command or no contest context' }
end
function M.handle_command(opts)
local cmd = parse_command(opts.fargs)
if cmd.type == 'error' then
logger.log(cmd.message, vim.log.levels.ERROR)
return
end
if cmd.type == 'action' then
if cmd.action == 'test' then
toggle_test_panel(cmd.debug)
elseif cmd.action == 'next' then
navigate_problem(1, cmd.language)
elseif cmd.action == 'prev' then
navigate_problem(-1, cmd.language)
end
return
end
if cmd.type == 'platform_only' then
set_platform(cmd.platform)
return
end
if cmd.type == 'contest_setup' then
if set_platform(cmd.platform) then
state.contest_id = cmd.contest
if vim.tbl_contains(config.scrapers, cmd.platform) then
local metadata_result = scrape.scrape_contest_metadata(cmd.platform, cmd.contest)
if not metadata_result.success then
logger.log(
'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'),
vim.log.levels.WARN
)
else
logger.log(
('loaded %d problems for %s %s'):format(
#metadata_result.problems,
cmd.platform,
cmd.contest
)
)
end
end
end
return
end
if cmd.type == 'full_setup' then
if set_platform(cmd.platform) then
state.contest_id = cmd.contest
local problem_ids = {}
local has_metadata = false
if vim.tbl_contains(config.scrapers, cmd.platform) then
local metadata_result = scrape.scrape_contest_metadata(cmd.platform, cmd.contest)
if not metadata_result.success then
logger.log(
'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'),
vim.log.levels.ERROR
)
return
end
logger.log(
('loaded %d problems for %s %s'):format(
#metadata_result.problems,
cmd.platform,
cmd.contest
)
)
problem_ids = vim.tbl_map(function(prob)
return prob.id
end, metadata_result.problems)
has_metadata = true
else
cache.load()
local contest_data = cache.get_contest_data(cmd.platform, cmd.contest)
if contest_data and contest_data.problems then
problem_ids = vim.tbl_map(function(prob)
return prob.id
end, contest_data.problems)
has_metadata = true
end
end
if has_metadata and not vim.tbl_contains(problem_ids, cmd.problem) then
logger.log(
("Invalid problem '%s' for contest %s %s"):format(cmd.problem, cmd.platform, cmd.contest),
vim.log.levels.ERROR
)
return
end
setup_problem(cmd.contest, cmd.problem, cmd.language)
end
return
end
if cmd.type == 'cses_problem' then
if set_platform(cmd.platform) then
if vim.tbl_contains(config.scrapers, cmd.platform) then
local metadata_result = scrape.scrape_contest_metadata(cmd.platform, '')
if not metadata_result.success then
logger.log(
'failed to load contest metadata: ' .. (metadata_result.error or 'unknown error'),
vim.log.levels.WARN
)
end
end
setup_problem(cmd.problem, nil, cmd.language)
end
return
end
if cmd.type == 'problem_switch' then
if state.platform == 'cses' then
setup_problem(cmd.problem, nil, cmd.language)
else
setup_problem(state.contest_id, cmd.problem, cmd.language)
end
return
end
end
function M.setup(opts)
opts = opts or {}
user_config = opts
config = config_module.setup(user_config)
logger.set_config(config)
if not snippets_initialized then
snippets.setup(config)
snippets_initialized = true
end
end
function M.get_current_context()
return {
platform = state.platform,
contest_id = state.contest_id,
problem_id = state.problem_id,
}
end end
function M.is_initialized() function M.is_initialized()
return initialized return true
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 end
return M return M

View file

@ -1,12 +1,15 @@
local M = {} local M = {}
function M.log(msg, level, override) local config = nil
local debug = require('cp.config').get_config().debug or false
function M.set_config(user_config)
config = user_config
end
function M.log(msg, level)
level = level or vim.log.levels.INFO level = level or vim.log.levels.INFO
if level >= vim.log.levels.WARN or override or debug then if not config or config.debug or level >= vim.log.levels.WARN then
vim.schedule(function() vim.notify(('[cp.nvim]: %s'):format(msg), level)
vim.notify(('[cp.nvim]: %s'):format(msg), level)
end)
end end
end end

View file

@ -1,92 +0,0 @@
local picker_utils = require('cp.pickers')
local M = {}
local function contest_picker(platform, refresh, language)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
local fzf = require('fzf-lua')
local contests = picker_utils.get_platform_contests(platform, refresh)
if vim.tbl_isempty(contests) then
vim.notify(
("No contests found for platform '%s'"):format(platform_display_name),
vim.log.levels.WARN
)
return
end
local entries = vim.tbl_map(function(contest)
return contest.display_name
end, contests)
return fzf.fzf_exec(entries, {
prompt = ('Select Contest (%s)> '):format(platform_display_name),
fzf_opts = {
['--header'] = 'ctrl-r: refresh',
},
actions = {
['default'] = function(selected)
if vim.tbl_isempty(selected) then
return
end
local selected_name = selected[1]
local contest = nil
for _, c in ipairs(contests) do
if c.display_name == selected_name then
contest = c
break
end
end
if contest then
local cp = require('cp')
local fargs = { platform, contest.id }
if language then
table.insert(fargs, '--lang')
table.insert(fargs, language)
end
cp.handle_command({ fargs = fargs })
end
end,
['ctrl-r'] = function()
contest_picker(platform, true, language)
end,
},
})
end
function M.pick(language)
local fzf = require('fzf-lua')
local platforms = picker_utils.get_platforms()
local entries = vim.tbl_map(function(platform)
return platform.display_name
end, platforms)
return fzf.fzf_exec(entries, {
prompt = 'Select Platform> ',
actions = {
['default'] = function(selected)
if vim.tbl_isempty(selected) then
return
end
local selected_name = selected[1]
local platform = nil
for _, p in ipairs(platforms) do
if p.display_name == selected_name then
platform = p
break
end
end
if platform then
contest_picker(platform.id, false, language)
end
end,
},
})
end
return M

View file

@ -1,68 +0,0 @@
local M = {}
local cache = require('cp.cache')
local constants = require('cp.constants')
local logger = require('cp.log')
local scraper = require('cp.scraper')
---@class cp.PlatformItem
---@field id string Platform identifier (e.g. "codeforces", "atcoder", "cses")
---@field display_name string Human-readable platform name (e.g. "Codeforces", "AtCoder", "CSES")
---@class cp.ContestItem
---@field id string Contest identifier (e.g. "1951", "abc324", "sorting")
---@field name string Full contest name (e.g. "Educational Codeforces Round 168")
---@field display_name string Formatted display name for picker
---@class cp.ProblemItem
---@field id string Problem identifier (e.g. "a", "b", "c")
---@field name string Problem name (e.g. "Two Permutations", "Painting Walls")
---@field display_name string Formatted display name for picker
---@return cp.PlatformItem[]
function M.get_platforms()
local config = require('cp.config').get_config()
local result = {}
for _, platform in ipairs(constants.PLATFORMS) do
if config.platforms[platform] then
table.insert(result, {
id = platform,
display_name = constants.PLATFORM_DISPLAY_NAMES[platform] or platform,
})
end
end
return result
end
---@param platform string
---@param refresh? boolean
---@return cp.ContestItem[]
function M.get_platform_contests(platform, refresh)
cache.load()
local picker_contests = cache.get_contest_summaries(platform)
if refresh or vim.tbl_isempty(picker_contests) then
logger.log(
('Loading %s contests...'):format(constants.PLATFORM_DISPLAY_NAMES[platform]),
vim.log.levels.INFO,
true
)
local contests = scraper.scrape_contest_list(platform)
cache.set_contest_summaries(platform, contests)
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
)
end
return picker_contests
end
return M

View file

@ -1,99 +0,0 @@
local finders = require('telescope.finders')
local pickers = require('telescope.pickers')
local conf = require('telescope.config').values
local action_state = require('telescope.actions.state')
local actions = require('telescope.actions')
local picker_utils = require('cp.pickers')
local M = {}
local function contest_picker(opts, platform, refresh, language)
local constants = require('cp.constants')
local platform_display_name = constants.PLATFORM_DISPLAY_NAMES[platform]
local contests = picker_utils.get_platform_contests(platform, refresh)
if vim.tbl_isempty(contests) then
vim.notify(
('No contests found for platform: %s'):format(platform_display_name),
vim.log.levels.WARN
)
return
end
pickers
.new(opts, {
prompt_title = ('Select Contest (%s)'):format(platform_display_name),
results_title = '<c-r> refresh',
finder = finders.new_table({
results = contests,
entry_maker = function(entry)
return {
value = entry,
display = entry.display_name,
ordinal = entry.display_name,
}
end,
}),
sorter = conf.generic_sorter(opts),
attach_mappings = function(prompt_bufnr, map)
actions.select_default:replace(function()
local selection = action_state.get_selected_entry()
actions.close(prompt_bufnr)
if selection then
local cp = require('cp')
local fargs = { platform, selection.value.id }
if language then
table.insert(fargs, '--lang')
table.insert(fargs, language)
end
cp.handle_command({ fargs = fargs })
end
end)
map('i', '<c-r>', function()
actions.close(prompt_bufnr)
contest_picker(opts, platform, true, language)
end)
return true
end,
})
:find()
end
function M.pick(language)
local opts = {}
local platforms = picker_utils.get_platforms()
pickers
.new(opts, {
prompt_title = 'Select Platform',
finder = finders.new_table({
results = platforms,
entry_maker = function(entry)
return {
value = entry,
display = entry.display_name,
ordinal = entry.display_name,
}
end,
}),
sorter = conf.generic_sorter(opts),
attach_mappings = function(prompt_bufnr)
actions.select_default:replace(function()
local selection = action_state.get_selected_entry()
actions.close(prompt_bufnr)
if selection then
contest_picker(opts, selection.value.id, false, language)
end
end)
return true
end,
})
:find()
end
return M

69
lua/cp/problem.lua Normal file
View file

@ -0,0 +1,69 @@
---@class ProblemContext
---@field contest string Contest name (e.g. "atcoder", "codeforces")
---@field contest_id string Contest ID (e.g. "abc123", "1933")
---@field problem_id? string Problem ID for AtCoder/Codeforces (e.g. "a", "b")
---@field source_file string Source filename (e.g. "abc123a.cpp")
---@field binary_file string Binary output path (e.g. "build/abc123a.run")
---@field input_file string Input test file path (e.g. "io/abc123a.in")
---@field output_file string Output file path (e.g. "io/abc123a.out")
---@field expected_file string Expected output path (e.g. "io/abc123a.expected")
---@field problem_name string Canonical problem identifier (e.g. "abc123a")
local M = {}
---@param contest string
---@param contest_id string
---@param problem_id? string
---@param config cp.Config
---@param language? string
---@return ProblemContext
function M.create_context(contest, contest_id, problem_id, config, language)
vim.validate({
contest = { contest, 'string' },
contest_id = { contest_id, 'string' },
problem_id = { problem_id, { 'string', 'nil' }, true },
config = { config, 'table' },
language = { language, { 'string', 'nil' }, true },
})
local contest_config = config.contests[contest]
if not contest_config then
error(("No contest config found for '%s'"):format(contest))
end
local target_language = language or contest_config.default_language
local language_config = contest_config[target_language]
if not language_config then
error(("No language config found for '%s' in contest '%s'"):format(target_language, contest))
end
if not language_config.extension then
error(
("No extension configured for language '%s' in contest '%s'"):format(target_language, contest)
)
end
local base_name
if config.filename then
local source_file = config.filename(contest, contest_id, problem_id, config, language)
base_name = vim.fn.fnamemodify(source_file, ':t:r')
else
local default_filename = require('cp.config').default_filename
base_name = default_filename(contest_id, problem_id)
end
local source_file = base_name .. '.' .. language_config.extension
return {
contest = contest,
contest_id = contest_id,
problem_id = problem_id,
source_file = source_file,
binary_file = ('build/%s.run'):format(base_name),
input_file = ('io/%s.cpin'):format(base_name),
output_file = ('io/%s.cpout'):format(base_name),
expected_file = ('io/%s.expected'):format(base_name),
problem_name = base_name,
}
end
return M

View file

@ -1,30 +0,0 @@
local M = {}
local cache = require('cp.cache')
local logger = require('cp.log')
local state = require('cp.state')
---@return boolean
function M.restore_from_current_file()
cache.load()
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)
return false
end
local setup = require('cp.setup')
state.set_problem_id(file_state.problem_id)
setup.setup_contest(
file_state.platform,
file_state.contest_id,
file_state.problem_id,
file_state.language
)
return true
end
return M

View file

@ -1,213 +0,0 @@
---@class ExecuteResult
---@field stdout string
---@field code integer
---@field time_ms number
---@field tled boolean
---@field mled boolean
---@field peak_mb number
---@field signal string|nil
---@class SubstitutableCommand
---@field source string substituted via '{source}'
---@field binary string substitued via '{binary}'
local M = {}
local constants = require('cp.constants')
local logger = require('cp.log')
local utils = require('cp.utils')
---@param cmd_template string[]
---@param substitutions SubstitutableCommand
---@return string[] string normalized with substitutions
local function substitute_template(cmd_template, substitutions)
local out = {}
for _, arg in ipairs(cmd_template) do
if arg == '{source}' and substitutions.source then
table.insert(out, substitutions.source)
elseif arg == '{binary}' and substitutions.binary then
table.insert(out, substitutions.binary)
else
table.insert(out, arg)
end
end
return out
end
function M.build_command(cmd_template, substitutions)
return substitute_template(cmd_template, substitutions)
end
---@param compile_cmd string[]
---@param substitutions SubstitutableCommand
---@param on_complete fun(r: {code: integer, stdout: string})
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)
local dt = (vim.uv.hrtime() - t0) / 1e6
local ansi = require('cp.ui.ansi')
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)
else
logger.log(('Compilation failed in %.1fms.'):format(dt))
end
vim.schedule(function()
on_complete(r)
end)
end)
end
local function parse_and_strip_time_v(output)
local s = output or ''
local last_i, from = nil, 1
while true do
local i = string.find(s, 'Command being timed:', from, true)
if not i then
break
end
last_i, from = i, i + 1
end
if not last_i then
return s, 0
end
local tab_before_marker = s:find('\t[^\t]*Command being timed:', 1)
local k
if tab_before_marker then
k = tab_before_marker - 1
else
k = last_i - 1
while k >= 1 do
local ch = s:sub(k, k)
if ch == '\n' then
break
end
k = k - 1
end
end
local head = s:sub(1, k)
local tail = s:sub(last_i)
local peak_kb = 0.0
for line in tail:gmatch('[^\n]+') do
local kb = line:match('Maximum resident set size %(kbytes%):%s*(%d+)')
if kb then
peak_kb = tonumber(kb) or 0
end
end
local peak_mb = peak_kb / 1024.0
return head, peak_mb
end
---@param on_complete fun(result: ExecuteResult)
function M.run(cmd, stdin, timeout_ms, memory_mb, on_complete)
local time_bin = utils.time_path()
local timeout_bin = utils.timeout_path()
local prog = table.concat(cmd, ' ')
local pre = {
('ulimit -v %d'):format(memory_mb * 1024),
}
local prefix = table.concat(pre, '; ') .. '; '
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)
local dt = (vim.uv.hrtime() - t0) / 1e6
local code = r.code or 0
local raw = r.stdout or ''
local cleaned, peak_mb = parse_and_strip_time_v(raw)
local tled = code == 124
local signal = nil
if code >= 128 then
signal = constants.signal_codes[code]
end
local lower = (cleaned or ''):lower()
local oom_hint = lower:find('std::bad_alloc', 1, true)
or lower:find('cannot allocate memory', 1, true)
or lower:find('out of memory', 1, true)
or lower:find('oom', 1, true)
or lower:find('enomem', 1, true)
local near_cap = peak_mb >= (0.90 * memory_mb)
local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint ~= nil and not tled)
if tled then
logger.log(('Execution timed out in %.1fms.'):format(dt))
elseif mled then
logger.log(('Execution memory limit exceeded in %.1fms.'):format(dt))
elseif code ~= 0 then
logger.log(('Execution failed in %.1fms (exit code %d).'):format(dt, code))
else
logger.log(('Execution successful in %.1fms.'):format(dt))
end
vim.schedule(function()
on_complete({
stdout = cleaned,
code = code,
time_ms = dt,
tled = tled,
mled = mled,
peak_mb = peak_mb,
signal = signal,
})
end)
end)
end
---@param debug boolean?
---@param on_complete fun(result: {success: boolean, output: string?})
function M.compile_problem(debug, on_complete)
local state = require('cp.state')
local config = require('cp.config').get_config()
local platform = state.get_platform()
local language = state.get_language() or config.platforms[platform].default_language
local eff = config.runtime.effective[platform][language]
local source_file = state.get_source_file()
if source_file then
local buf = vim.fn.bufnr(source_file)
if buf ~= -1 and vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].modified then
vim.api.nvim_buf_call(buf, function()
vim.cmd.write({ mods = { silent = true, noautocmd = true } })
end)
end
end
local compile_config = (debug and eff.commands.debug) or eff.commands.build
if not compile_config then
on_complete({ success = true, output = nil })
return
end
require('cp.utils').ensure_dirs()
local binary = debug and state.get_debug_file() or state.get_binary_file()
local substitutions = { source = state.get_source_file(), binary = binary }
M.compile(compile_config, substitutions, function(r)
if r.code ~= 0 then
on_complete({ success = false, output = r.stdout or 'unknown error' })
else
on_complete({ success = true, output = nil })
end
end)
end
return M

View file

@ -1,338 +0,0 @@
---@class RanTestCase
---@field index number
---@field input string
---@field expected string
---@field status "pending"|"pass"|"fail"|"running"|"tle"|"mle"
---@field actual string?
---@field actual_highlights? Highlight[]
---@field time_ms number?
---@field error string?
---@field stderr string?
---@field selected boolean
---@field code number?
---@field ok boolean?
---@field signal string?
---@field tled boolean?
---@field mled boolean?
---@field rss_mb number
---@class ProblemConstraints
---@field timeout_ms number
---@field memory_mb number
---@class PanelState
---@field test_cases RanTestCase[]
---@field current_index number
---@field buffer number?
---@field namespace number?
---@field is_active boolean
---@field saved_layout table?
---@field constraints ProblemConstraints?
local M = {}
local cache = require('cp.cache')
local config = require('cp.config').get_config()
local constants = require('cp.constants')
local execute = require('cp.runner.execute')
local logger = require('cp.log')
local state = require('cp.state')
---@type PanelState
local panel_state = {
test_cases = {},
current_index = 1,
buffer = nil,
namespace = nil,
is_active = false,
saved_layout = nil,
constraints = nil,
}
---@param platform string
---@param contest_id string
---@param problem_id string|nil
---@return ProblemConstraints|nil
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 }
end
return nil
end
--- Normalize raw problem output to a "canonical" version
--- Usually, most contests ignore leading/trailing whitespace and empty lines
---@param lines string
local function normalize_lines(lines)
local normalized = {}
for _, line in
ipairs(vim.tbl_values(vim.split(((lines or ''):gsub('\r', '')), '\n', { plain = true })))
do
local trimmed_line = vim.trim(line)
if trimmed_line ~= '' then
table.insert(normalized, trimmed_line)
end
end
return table.concat(normalized, '\n')
end
---@param test_cases TestCase[]
---@return RanTestCase[]
local function create_sentinal_panel_data(test_cases)
local out = {}
for i, tc in ipairs(test_cases) do
out[i] = {
index = tc.index or i,
input = tc.input or '',
expected = tc.expected or '',
status = 'pending',
selected = false,
}
end
return out
end
---@param cmd string[]
---@return string[]
local function build_command(cmd, substitutions)
return execute.build_command(cmd, substitutions)
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 })
local function run_single_test_case(test_case, debug, on_complete)
local source_file = state.get_source_file()
local binary_file = debug and state.get_debug_file() or state.get_binary_file()
local substitutions = { source = source_file, binary = binary_file }
local platform_config = config.platforms[state.get_platform() or '']
local language = state.get_language() or platform_config.default_language
local eff = config.runtime.effective[state.get_platform() or ''][language]
local run_template = eff and eff.commands and eff.commands.run or {}
local cmd = build_command(run_template, substitutions)
local stdin_content = (test_case.input or '') .. '\n'
local timeout_ms = (panel_state.constraints and panel_state.constraints.timeout_ms) or 0
local memory_mb = panel_state.constraints and panel_state.constraints.memory_mb or 0
execute.run(cmd, stdin_content, timeout_ms, memory_mb, function(r)
local ansi = require('cp.ui.ansi')
local out = r.stdout or ''
local highlights = {}
if out ~= '' then
if config.ui.ansi then
local parsed = ansi.parse_ansi_text(out)
out = table.concat(parsed.lines, '\n')
highlights = parsed.highlights
else
out = out:gsub('\027%[[%d;]*[a-zA-Z]', '')
end
end
local max_lines = config.ui.panel.max_output_lines
local lines = vim.split(out, '\n')
if #lines > max_lines then
local trimmed = {}
for i = 1, max_lines do
table.insert(trimmed, lines[i])
end
table.insert(trimmed, string.format('... (output trimmed after %d lines)', max_lines))
out = table.concat(trimmed, '\n')
end
local expected = test_case.expected or ''
local ok = normalize_lines(out) == normalize_lines(expected)
local signal = r.signal
if not signal and r.code and r.code >= 128 then
signal = constants.signal_codes[r.code]
end
local status
if r.tled then
status = 'tle'
elseif r.mled then
status = 'mle'
elseif ok then
status = 'pass'
else
status = 'fail'
end
on_complete({
status = status,
actual = out,
actual_highlights = highlights,
error = (r.code ~= 0 and not ok) and out or '',
stderr = '',
time_ms = r.time_ms,
code = r.code,
ok = ok,
signal = signal,
tled = r.tled or false,
mled = r.mled or false,
rss_mb = r.peak_mb or 0,
})
end)
end
---@return boolean
function M.load_test_cases()
local tcs = cache.get_test_cases(
state.get_platform() or '',
state.get_contest_id() or '',
state.get_problem_id()
)
panel_state.test_cases = create_sentinal_panel_data(tcs)
panel_state.current_index = 1
panel_state.constraints = load_constraints_from_cache(
state.get_platform() or '',
state.get_contest_id() or '',
state.get_problem_id()
)
logger.log(('Loaded %d test case(s)'):format(#tcs), vim.log.levels.INFO)
return #tcs > 0
end
---@param debug boolean?
---@param on_complete fun(result: RanTestCase?)
function M.run_combined_test(debug, on_complete)
local combined = cache.get_combined_test(
state.get_platform() or '',
state.get_contest_id() or '',
state.get_problem_id()
)
if not combined then
logger.log('No combined test found', vim.log.levels.ERROR)
on_complete(nil)
return
end
local ran_test = {
index = 1,
input = combined.input,
expected = combined.expected,
status = 'running',
actual = nil,
time_ms = nil,
code = nil,
ok = nil,
signal = nil,
tled = false,
mled = false,
rss_mb = 0,
selected = true,
}
run_single_test_case(ran_test, debug, function(result)
on_complete(result)
end)
end
---@param index number
---@param debug boolean?
---@param on_complete fun(success: boolean)
function M.run_test_case(index, debug, on_complete)
local tc = panel_state.test_cases[index]
if not tc then
on_complete(false)
return
end
tc.status = 'running'
run_single_test_case(tc, debug, function(r)
tc.status = r.status
tc.actual = r.actual
tc.actual_highlights = r.actual_highlights
tc.error = r.error
tc.stderr = r.stderr
tc.time_ms = r.time_ms
tc.code = r.code
tc.ok = r.ok
tc.signal = r.signal
tc.tled = r.tled
tc.mled = r.mled
tc.rss_mb = r.rss_mb
on_complete(true)
end)
end
---@param indices? integer[]
---@param debug boolean?
---@param on_each? fun(index: integer, total: integer)
---@param on_done fun(results: RanTestCase[])
function M.run_all_test_cases(indices, debug, on_each, on_done)
local to_run = indices
if not to_run then
to_run = {}
for i = 1, #panel_state.test_cases do
to_run[i] = i
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)
end
run_next(1)
end
---@return PanelState
function M.get_panel_state()
return panel_state
end
---@param output string|nil
---@return nil
function M.handle_compilation_failure(output)
local ansi = require('cp.ui.ansi')
local txt
local hl = {}
if config.ui.ansi then
local p = ansi.parse_ansi_text(output or '')
txt = table.concat(p.lines, '\n')
hl = p.highlights
else
txt = (output or ''):gsub('\027%[[%d;]*[a-zA-Z]', '')
end
for _, tc in ipairs(panel_state.test_cases) do
tc.status = 'fail'
tc.actual = txt
tc.actual_highlights = hl
tc.error = 'Compilation failed'
tc.stderr = ''
tc.time_ms = 0
tc.code = 1
tc.ok = false
tc.signal = ''
tc.tled = false
tc.mled = false
tc.rss_mb = 0
end
end
return M

View file

@ -1,386 +0,0 @@
---@class StatusInfo
---@field text string
---@field highlight_group string
local M = {}
local function strwidth(s)
return vim.api.nvim_strwidth(s)
end
local exit_code_names = {
[128] = 'SIGHUP',
[129] = 'SIGINT',
[130] = 'SIGQUIT',
[131] = 'SIGILL',
[132] = 'SIGTRAP',
[133] = 'SIGABRT',
[134] = 'SIGBUS',
[135] = 'SIGFPE',
[136] = 'SIGKILL',
[137] = 'SIGUSR1',
[138] = 'SIGSEGV',
[139] = 'SIGUSR2',
[140] = 'SIGPIPE',
[141] = 'SIGALRM',
[142] = 'SIGTERM',
[143] = 'SIGCHLD',
}
---@param ran_test_case RanTestCase
---@return StatusInfo
function M.get_status_info(ran_test_case)
if ran_test_case.status == 'pending' then
return { text = '...', highlight_group = 'CpTestNA' }
elseif ran_test_case.status == 'running' then
return { text = 'RUN', highlight_group = 'CpTestNA' }
end
if ran_test_case.ok then
return { text = 'AC', highlight_group = 'CpTestAC' }
end
if ran_test_case.tled then
return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif ran_test_case.mled then
return { text = 'MLE', highlight_group = 'CpTestMLE' }
elseif ran_test_case.code and ran_test_case.code >= 128 then
return { text = 'RTE', highlight_group = 'CpTestRTE' }
elseif ran_test_case.code == 0 and not ran_test_case.ok then
return { text = 'WA', highlight_group = 'CpTestWA' }
end
return { text = 'N/A', highlight_group = 'CpTestNA' }
end
local function format_exit_code(code)
if not code then
return ''
end
local signal_name = exit_code_names[code]
return signal_name and string.format('%d (%s)', code, signal_name) or tostring(code)
end
local function compute_cols(test_state)
local w = { num = 5, status = 8, time = 6, timeout = 8, rss = 8, memory = 8, exit = 11 }
local timeout_str = ''
local memory_str = ''
if test_state.constraints then
timeout_str = tostring(test_state.constraints.timeout_ms)
memory_str = string.format('%.0f', test_state.constraints.memory_mb)
end
for i, tc in ipairs(test_state.test_cases) do
local prefix = (i == test_state.current_index) and '>' or ' '
w.num = math.max(w.num, strwidth(' ' .. prefix .. i .. ' '))
w.status = math.max(w.status, strwidth(' ' .. M.get_status_info(tc).text .. ' '))
local time_str = tc.time_ms and string.format('%.2f', tc.time_ms) or ''
w.time = math.max(w.time, strwidth(' ' .. time_str .. ' '))
w.timeout = math.max(w.timeout, strwidth(' ' .. timeout_str .. ' '))
local rss_str = (tc.rss_mb and string.format('%.0f', tc.rss_mb)) or ''
w.rss = math.max(w.rss, strwidth(' ' .. rss_str .. ' '))
w.memory = math.max(w.memory, strwidth(' ' .. memory_str .. ' '))
w.exit = math.max(w.exit, strwidth(' ' .. format_exit_code(tc.code) .. ' '))
end
w.num = math.max(w.num, strwidth(' # '))
w.status = math.max(w.status, strwidth(' Status '))
w.time = math.max(w.time, strwidth(' Runtime (ms) '))
w.timeout = math.max(w.timeout, strwidth(' Time (ms) '))
w.rss = math.max(w.rss, strwidth(' RSS (MB) '))
w.memory = math.max(w.memory, strwidth(' Mem (MB) '))
w.exit = math.max(w.exit, strwidth(' Exit Code '))
local sum = w.num + w.status + w.time + w.timeout + w.rss + w.memory + w.exit
local inner = sum + 6
local total = inner + 2
return { w = w, sum = sum, inner = inner, total = total }
end
local function center(text, width)
local pad = width - strwidth(text)
if pad <= 0 then
return text
end
local left = math.ceil(pad / 2)
return string.rep(' ', left) .. text .. string.rep(' ', pad - left)
end
local function format_num_column(prefix, idx, width)
local num_str = tostring(idx)
local content = (#num_str == 1) and (' ' .. prefix .. ' ' .. num_str .. ' ')
or (' ' .. prefix .. num_str .. ' ')
local total_pad = width - strwidth(content)
if total_pad <= 0 then
return content
end
local left_pad = math.ceil(total_pad / 2)
local right_pad = total_pad - left_pad
return string.rep(' ', left_pad) .. content .. string.rep(' ', right_pad)
end
local function top_border(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function row_sep(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function bottom_border(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function flat_fence_above(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function flat_fence_below(c)
local w = c.w
return ''
.. string.rep('', w.num)
.. ''
.. string.rep('', w.status)
.. ''
.. string.rep('', w.time)
.. ''
.. string.rep('', w.timeout)
.. ''
.. string.rep('', w.rss)
.. ''
.. string.rep('', w.memory)
.. ''
.. string.rep('', w.exit)
.. ''
end
local function flat_bottom_border(c)
return '' .. string.rep('', c.inner) .. ''
end
local function header_line(c)
local w = c.w
return ''
.. center('#', w.num)
.. ''
.. center('Status', w.status)
.. ''
.. center('Runtime (ms)', w.time)
.. ''
.. center('Time (ms)', w.timeout)
.. ''
.. center('RSS (MB)', w.rss)
.. ''
.. center('Mem (MB)', w.memory)
.. ''
.. center('Exit Code', w.exit)
.. ''
end
local function data_row(c, idx, tc, is_current, test_state)
local w = c.w
local prefix = is_current and '>' or ' '
local status = M.get_status_info(tc)
local time = tc.time_ms and string.format('%.2f', tc.time_ms) or ''
local exit = format_exit_code(tc.code)
local timeout = ''
local memory = ''
if test_state.constraints then
timeout = tostring(test_state.constraints.timeout_ms)
memory = string.format('%.0f', test_state.constraints.memory_mb)
end
local rss = (tc.rss_mb and string.format('%.0f', tc.rss_mb)) or ''
local line = ''
.. format_num_column(prefix, idx, w.num)
.. ''
.. center(status.text, w.status)
.. ''
.. center(time, w.time)
.. ''
.. center(timeout, w.timeout)
.. ''
.. center(rss, w.rss)
.. ''
.. center(memory, w.memory)
.. ''
.. center(exit, w.exit)
.. ''
local hi
if status.text ~= '' then
local status_pos = line:find(status.text, 1, true)
if status_pos then
hi = {
col_start = status_pos - 1,
col_end = status_pos - 1 + #status.text,
highlight_group = status.highlight_group,
}
end
end
return line, hi
end
---@param test_state PanelState
---@return string[] lines
---@return Highlight[] highlights
---@return integer current_test_line
function M.render_test_list(test_state)
local lines, highlights = {}, {}
local c = compute_cols(test_state)
local current_test_line = nil
table.insert(lines, top_border(c))
table.insert(lines, header_line(c))
table.insert(lines, row_sep(c))
for i, tc in ipairs(test_state.test_cases) do
local is_current = (i == test_state.current_index)
local row, hi = data_row(c, i, tc, is_current, test_state)
table.insert(lines, row)
if is_current then
current_test_line = #lines
end
if hi then
hi.line = #lines - 1
table.insert(highlights, hi)
end
local has_next = (i < #test_state.test_cases)
local has_input = is_current and tc.input and tc.input ~= ''
if has_input then
table.insert(lines, flat_fence_above(c))
local input_header = 'Input:'
local header_pad = c.inner - #input_header
table.insert(lines, '' .. input_header .. string.rep(' ', header_pad) .. '')
for _, input_line in ipairs(vim.split(tc.input, '\n', { plain = true, trimempty = false })) do
local s = input_line or ''
if strwidth(s) > c.inner then
s = string.sub(s, 1, c.inner)
end
local pad = c.inner - strwidth(s)
table.insert(lines, '' .. s .. string.rep(' ', pad) .. '')
end
if has_next then
table.insert(lines, flat_fence_below(c))
else
table.insert(lines, flat_bottom_border(c))
end
else
if has_next then
table.insert(lines, row_sep(c))
else
table.insert(lines, bottom_border(c))
end
end
end
return lines, highlights, current_test_line or 1
end
---@param ran_test_case RanTestCase?
---@return string
function M.render_status_bar(ran_test_case)
if not ran_test_case then
return ''
end
local parts = {}
if ran_test_case.time_ms then
table.insert(parts, string.format('%.2fms', ran_test_case.time_ms))
end
if ran_test_case.code then
table.insert(parts, string.format('Exit: %d', ran_test_case.code))
end
return table.concat(parts, '')
end
---@return table<string, table>
function M.get_highlight_groups()
return {
CpTestAC = { link = 'DiagnosticOk' },
CpTestWA = { link = 'DiagnosticError' },
CpTestTLE = { link = 'DiagnosticWarn' },
CpTestMLE = { link = 'DiagnosticWarn' },
CpTestRTE = { link = 'DiagnosticHint' },
CpTestNA = { link = 'Comment' },
}
end
function M.setup_highlights()
local groups = M.get_highlight_groups()
for name, opts in pairs(groups) do
vim.api.nvim_set_hl(0, name, opts)
end
end
return M

291
lua/cp/scrape.lua Normal file
View file

@ -0,0 +1,291 @@
---@class ScraperTestCase
---@field input string
---@field expected string
---@class ScraperResult
---@field success boolean
---@field problem_id string
---@field url? string
---@field tests? ScraperTestCase[]
---@field error? string
local M = {}
local cache = require('cp.cache')
local logger = require('cp.log')
local function get_plugin_path()
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
return vim.fn.fnamemodify(plugin_path, ':h:h:h')
end
local function ensure_io_directory()
vim.fn.mkdir('io', 'p')
end
local function check_internet_connectivity()
local result = vim.system({ 'ping', '-c', '1', '-W', '3', '8.8.8.8' }, { text = true }):wait()
return result.code == 0
end
local function setup_python_env()
local plugin_path = 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
end
if vim.fn.isdirectory(venv_dir) == 0 then
logger.log('setting up Python environment for scrapers...')
local result = vim.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true }):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
return true
end
---@param platform string
---@param contest_id string
---@return {success: boolean, problems?: table[], error?: string}
function M.scrape_contest_metadata(platform, contest_id)
vim.validate({
platform = { platform, 'string' },
contest_id = { contest_id, 'string' },
})
cache.load()
local cached_data = cache.get_contest_data(platform, contest_id)
if cached_data then
return {
success = true,
problems = cached_data.problems,
}
end
if not check_internet_connectivity() then
return {
success = false,
error = 'No internet connection available',
}
end
if not setup_python_env() then
return {
success = false,
error = 'Python environment setup failed',
}
end
local plugin_path = get_plugin_path()
local scraper_path = plugin_path .. '/scrapers/' .. platform .. '.py'
local args
if platform == 'cses' then
args = {
'uv',
'run',
'--directory',
plugin_path,
scraper_path,
'metadata',
}
else
args = {
'uv',
'run',
'--directory',
plugin_path,
scraper_path,
'metadata',
contest_id,
}
end
local result = vim
.system(args, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
:wait()
if result.code ~= 0 then
return {
success = false,
error = 'Failed to run metadata scraper: ' .. (result.stderr or 'Unknown error'),
}
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
return {
success = false,
error = 'Failed to parse metadata scraper output: ' .. tostring(data),
}
end
if not data.success then
return data
end
local problems_list
if platform == 'cses' then
problems_list = data.categories and data.categories['CSES Problem Set'] or {}
else
problems_list = data.problems or {}
end
cache.set_contest_data(platform, contest_id, problems_list)
return {
success = true,
problems = problems_list,
}
end
---@param ctx ProblemContext
---@return {success: boolean, problem_id: string, test_count?: number, test_cases?: ScraperTestCase[], url?: string, error?: string}
function M.scrape_problem(ctx)
vim.validate({
ctx = { ctx, 'table' },
})
ensure_io_directory()
if vim.fn.filereadable(ctx.input_file) == 1 and vim.fn.filereadable(ctx.expected_file) == 1 then
local base_name = vim.fn.fnamemodify(ctx.input_file, ':r')
local test_cases = {}
local i = 1
while true do
local input_file = base_name .. '.' .. i .. '.cpin'
local expected_file = base_name .. '.' .. i .. '.cpout'
if vim.fn.filereadable(input_file) == 1 and vim.fn.filereadable(expected_file) == 1 then
local input_content = table.concat(vim.fn.readfile(input_file), '\n')
local expected_content = table.concat(vim.fn.readfile(expected_file), '\n')
table.insert(test_cases, {
index = i,
input = input_content,
output = expected_content,
})
i = i + 1
else
break
end
end
return {
success = true,
problem_id = ctx.problem_name,
test_count = #test_cases,
test_cases = test_cases,
}
end
if not check_internet_connectivity() then
return {
success = false,
problem_id = ctx.problem_name,
error = 'No internet connection available',
}
end
if not setup_python_env() then
return {
success = false,
problem_id = ctx.problem_name,
error = 'Python environment setup failed',
}
end
local plugin_path = get_plugin_path()
local scraper_path = plugin_path .. '/scrapers/' .. ctx.contest .. '.py'
local args
if ctx.contest == 'cses' then
args = {
'uv',
'run',
'--directory',
plugin_path,
scraper_path,
'tests',
ctx.contest_id,
}
else
args = {
'uv',
'run',
'--directory',
plugin_path,
scraper_path,
'tests',
ctx.contest_id,
ctx.problem_id,
}
end
local result = vim
.system(args, {
cwd = plugin_path,
text = true,
timeout = 30000,
})
:wait()
if result.code ~= 0 then
return {
success = false,
problem_id = ctx.problem_name,
error = 'Failed to run tests scraper: ' .. (result.stderr or 'Unknown error'),
}
end
local ok, data = pcall(vim.json.decode, result.stdout)
if not ok then
return {
success = false,
problem_id = ctx.problem_name,
error = 'Failed to parse tests scraper output: ' .. tostring(data),
}
end
if not data.success then
return data
end
if data.tests and #data.tests > 0 then
local base_name = vim.fn.fnamemodify(ctx.input_file, ':r')
for i, test_case in ipairs(data.tests) do
local input_file = base_name .. '.' .. i .. '.cpin'
local expected_file = base_name .. '.' .. i .. '.cpout'
local input_content = test_case.input:gsub('\r', '')
local expected_content = test_case.expected:gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n', true), input_file)
vim.fn.writefile(vim.split(expected_content, '\n', true), expected_file)
end
end
return {
success = true,
problem_id = ctx.problem_name,
test_count = data.tests and #data.tests or 0,
test_cases = data.tests,
url = data.url,
}
end
return M

View file

@ -1,229 +0,0 @@
local M = {}
local constants = require('cp.constants')
local logger = require('cp.log')
local utils = require('cp.utils')
local function syshandle(result)
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 }
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) }
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 })
vim.list_extend(cmd, args)
logger.log('scraper cmd: ' .. table.concat(cmd, ' '))
local env = vim.fn.environ()
env.VIRTUAL_ENV = ''
env.PYTHONPATH = ''
env.CONDA_PREFIX = ''
if opts and opts.ndjson then
local uv = vim.loop
local stdout = uv.new_pipe(false)
local stderr = uv.new_pipe(false)
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()
end
end)
if not handle then
logger.log('Failed to start scraper process', vim.log.levels.ERROR)
return { success = false, error = 'spawn failed' }
end
uv.read_start(stdout, function(_, data)
if data == nil then
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
return
end
buf = buf .. data
while true do
local s, e = buf:find('\n', 1, true)
if not s then
break
end
local line = buf:sub(1, s - 1)
buf = buf:sub(e + 1)
local ok, ev = pcall(vim.json.decode, line)
if ok and opts.on_event then
opts.on_event(ev)
end
end
end)
uv.read_start(stderr, function(_, _) end)
return
end
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)
else
vim.system(cmd, sysopts, function(result)
if opts and opts.on_exit then
return opts.on_exit(syshandle(result))
end
end)
end
end
function M.scrape_contest_metadata(platform, contest_id, callback)
run_scraper(platform, 'metadata', { contest_id }, {
on_exit = function(result)
if not result or not result.success then
logger.log(
("Failed to scrape metadata for %s contest '%s'."):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id
),
vim.log.levels.ERROR
)
return
end
local data = result.data or {}
if not data.problems or #data.problems == 0 then
logger.log(
("No problems returned for %s contest '%s'."):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id
),
vim.log.levels.ERROR
)
return
end
if type(callback) == 'function' then
callback(data)
end
end,
})
end
function M.scrape_contest_list(platform)
local result = run_scraper(platform, 'contests', {}, { sync = true })
if not result or not result.success or not (result.data and result.data.contests) then
logger.log(
('Could not scrape contests list for platform %s: %s'):format(
platform,
(result and result.error) or 'unknown'
),
vim.log.levels.ERROR
)
return {}
end
return result.data.contests
end
---@param platform string
---@param contest_id string
---@param callback fun(data: table)|nil
function M.scrape_all_tests(platform, contest_id, callback)
run_scraper(platform, 'tests', { contest_id }, {
ndjson = true,
on_event = function(ev)
if ev.done then
return
end
if ev.error and ev.problem_id then
logger.log(
("Failed to load tests for problem '%s' in contest '%s': %s"):format(
ev.problem_id,
contest_id,
ev.error
),
vim.log.levels.WARN
)
return
end
if not ev.problem_id or not ev.tests then
return
end
vim.schedule(function()
require('cp.utils').ensure_dirs()
local config = require('cp.config')
local base_name = config.default_filename(contest_id, ev.problem_id)
for i, t in ipairs(ev.tests) do
local input_file = 'io/' .. base_name .. '.' .. i .. '.cpin'
local expected_file = 'io/' .. base_name .. '.' .. i .. '.cpout'
local input_content = t.input:gsub('\r', '')
local expected_content = t.expected:gsub('\r', '')
vim.fn.writefile(vim.split(input_content, '\n'), input_file)
vim.fn.writefile(vim.split(expected_content, '\n'), expected_file)
end
if type(callback) == 'function' then
callback({
combined = ev.combined,
tests = ev.tests,
timeout_ms = ev.timeout_ms or 0,
memory_mb = ev.memory_mb or 0,
interactive = ev.interactive or false,
multi_test = ev.multi_test or false,
problem_id = ev.problem_id,
})
end
end)
end,
})
end
return M

View file

@ -1,394 +0,0 @@
local M = {}
local cache = require('cp.cache')
local config_module = require('cp.config')
local constants = require('cp.constants')
local helpers = require('cp.helpers')
local logger = require('cp.log')
local scraper = require('cp.scraper')
local state = require('cp.state')
---Get the language of the current file from cache
---@return string?
local function get_current_file_language()
local current_file = vim.fn.expand('%:p')
if current_file == '' then
return nil
end
cache.load()
local file_state = cache.get_file_state(current_file)
return file_state and file_state.language or nil
end
---Check if a problem file exists for any enabled language
---@param platform string
---@param contest_id string
---@param problem_id string
---@return string?
local function get_existing_problem_language(platform, contest_id, problem_id)
local config = config_module.get_config()
local platform_config = config.platforms[platform]
if not platform_config then
return nil
end
for _, lang_id in ipairs(platform_config.enabled_languages) do
local effective = config.runtime.effective[platform][lang_id]
if effective and effective.extension then
local basename = config.filename
and config.filename(platform, contest_id, problem_id, config, lang_id)
or config_module.default_filename(contest_id, problem_id)
local filepath = basename .. '.' .. effective.extension
if vim.fn.filereadable(filepath) == 1 then
return lang_id
end
end
end
return nil
end
---@class TestCaseLite
---@field input string
---@field expected string
---@class ScrapeEvent
---@field problem_id string
---@field tests TestCaseLite[]|nil
---@field timeout_ms integer|nil
---@field memory_mb integer|nil
---@field interactive boolean|nil
---@field error string|nil
---@field done boolean|nil
---@field succeeded integer|nil
---@field failed integer|nil
---@param cd table|nil
---@return boolean
local function is_metadata_ready(cd)
return cd
and type(cd.problems) == 'table'
and #cd.problems > 0
and type(cd.index_map) == 'table'
and next(cd.index_map) ~= nil
or false
end
---@param platform string
---@param contest_id string
---@param problems table
local function start_tests(platform, contest_id, problems)
local cached_len = #vim.tbl_filter(function(p)
return not vim.tbl_isempty(cache.get_test_cases(platform, contest_id, p.id))
end, problems)
if cached_len ~= #problems then
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)
end
for i, t in ipairs(ev.tests) do
cached_tests[i] = { index = i, input = t.input, expected = t.expected }
end
cache.set_test_cases(
platform,
contest_id,
ev.problem_id,
ev.combined,
cached_tests,
ev.timeout_ms or 0,
ev.memory_mb or 0,
ev.interactive,
ev.multi_test
)
local io_state = state.get_io_view_state()
if io_state then
local combined_test = cache.get_combined_test(platform, contest_id, state.get_problem_id())
if combined_test then
local input_lines = vim.split(combined_test.input, '\n')
require('cp.utils').update_buffer_content(io_state.input_buf, input_lines, nil, nil)
end
end
end)
end
end
---@param platform string
---@param contest_id string
---@param problem_id? string
---@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()
state.set_platform(platform)
state.set_contest_id(contest_id)
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)
return
end
end
local is_new_contest = old_platform ~= platform and old_contest_id ~= contest_id
cache.load()
local function proceed(contest_data)
local problems = contest_data.problems
local pid = problem_id and problem_id or problems[1].id
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
vim.ui.open(contest_data.url:format(pid))
end
end
local contest_data = cache.get_contest_data(platform, contest_id)
if not is_metadata_ready(contest_data) then
local cfg = config_module.get_config()
local lang = language or (cfg.platforms[platform] and cfg.platforms[platform].default_language)
vim.cmd.only({ mods = { silent = true } })
local bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_win_set_buf(0, bufnr)
vim.bo[bufnr].filetype = lang or ''
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
vim.b[bufnr].cp_setup_done = true
end
end
state.set_provisional({
bufnr = bufnr,
platform = platform,
contest_id = contest_id,
language = lang,
requested_problem_id = problem_id,
token = vim.loop.hrtime(),
})
logger.log('Fetching contests problems...', vim.log.levels.INFO, true)
scraper.scrape_contest_metadata(
platform,
contest_id,
vim.schedule_wrap(function(result)
local problems = result.problems or {}
cache.set_contest_data(platform, contest_id, problems, result.url)
local prov = state.get_provisional()
if not prov or prov.platform ~= platform or prov.contest_id ~= contest_id then
return
end
local cd = cache.get_contest_data(platform, contest_id)
if not is_metadata_ready(cd) then
return
end
local pid = prov.requested_problem_id
if not pid or not cd.index_map or not cd.index_map[pid] then
pid = cd.problems[1] and cd.problems[1].id or nil
end
if not pid then
return
end
proceed(cd)
end)
)
return
end
proceed(contest_data)
end
---@param problem_id string
---@param language? string
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)
return
end
local old_problem_id = state.get_problem_id()
state.set_problem_id(problem_id)
if old_problem_id ~= problem_id 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
local utils = require('cp.utils')
utils.update_buffer_content(io_state.output_buf, {}, nil, nil)
end
end
local config = config_module.get_config()
local lang = language
or (config.platforms[platform] and config.platforms[platform].default_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)
return
end
end
state.set_language(lang)
local source_file = state.get_source_file(lang)
if not source_file then
return
end
vim.fn.mkdir(vim.fn.fnamemodify(source_file, ':h'), 'p')
local prov = state.get_provisional()
if prov and prov.platform == platform and prov.contest_id == (state.get_contest_id() or '') then
if vim.api.nvim_buf_is_valid(prov.bufnr) then
local existing_bufnr = vim.fn.bufnr(source_file)
if existing_bufnr ~= -1 then
vim.api.nvim_buf_delete(prov.bufnr, { force = true })
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),
bang = true,
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
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'),
platform,
state.get_contest_id() or '',
state.get_problem_id() or '',
lang
)
require('cp.ui.views').ensure_io_view()
state.set_provisional(nil)
return
end
else
state.set_provisional(nil)
end
end
vim.cmd.only({ mods = { silent = true } })
vim.cmd.e(source_file)
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
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'),
platform,
state.get_contest_id() or '',
state.get_problem_id() or '',
lang
)
end
---@param direction integer
---@param language? string
function M.navigate_problem(direction, language)
if direction == 0 then
return
end
direction = direction > 0 and 1 or -1
local platform = state.get_platform()
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)
return
end
cache.load()
local contest_data = cache.get_contest_data(platform, contest_id)
if not is_metadata_ready(contest_data) then
logger.log(
('No data available for %s contest %s.'):format(
constants.PLATFORM_DISPLAY_NAMES[platform],
contest_id
),
vim.log.levels.ERROR
)
return
end
local problems = contest_data.problems
local index = contest_data.index_map[current_problem_id]
local new_index = index + direction
if new_index < 1 or new_index > #problems then
return
end
logger.log(('navigate_problem: %s -> %s'):format(current_problem_id, problems[new_index].id))
local active_panel = state.get_active_panel()
if active_panel == 'run' then
require('cp.ui.views').disable()
end
local lang = nil
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)
return
end
lang = language
else
local existing_lang =
get_existing_problem_language(platform, contest_id, problems[new_index].id)
if existing_lang then
lang = existing_lang
else
lang = get_current_file_language()
if lang then
local lang_result = config_module.get_language_for_platform(platform, lang)
if not lang_result.valid then
lang = nil
end
end
end
end
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
local utils = require('cp.utils')
utils.update_buffer_content(io_state.output_buf, {}, nil, nil)
end
M.setup_contest(platform, contest_id, problems[new_index].id, lang)
end
return M

130
lua/cp/snippets.lua Normal file
View file

@ -0,0 +1,130 @@
local M = {}
local logger = require('cp.log')
function M.setup(config)
local ok, ls = pcall(require, 'luasnip')
if not ok then
logger.log('LuaSnip not available - snippets disabled', vim.log.levels.INFO)
return
end
local s, i, fmt = ls.snippet, ls.insert_node, require('luasnip.extras.fmt').fmt
local constants = require('cp.constants')
local filetype_to_language = constants.filetype_to_language
local language_to_filetype = {}
for ext, lang in pairs(filetype_to_language) do
if not language_to_filetype[lang] then
language_to_filetype[lang] = ext
end
end
local template_definitions = {
cpp = {
codeforces = [[#include <bits/stdc++.h>
using namespace std;
void solve() {{
{}
}}
int main() {{
std::cin.tie(nullptr)->sync_with_stdio(false);
int tc = 1;
std::cin >> tc;
for (int t = 0; t < tc; ++t) {{
solve();
}}
return 0;
}}]],
atcoder = [[#include <bits/stdc++.h>
using namespace std;
void solve() {{
{}
}}
int main() {{
std::cin.tie(nullptr)->sync_with_stdio(false);
#ifdef LOCAL
int tc;
std::cin >> tc;
for (int t = 0; t < tc; ++t) {{
solve();
}}
#else
solve();
#endif
return 0;
}}]],
cses = [[#include <bits/stdc++.h>
using namespace std;
int main() {{
std::cin.tie(nullptr)->sync_with_stdio(false);
{}
return 0;
}}]],
},
python = {
codeforces = [[def solve():
{}
if __name__ == "__main__":
tc = int(input())
for _ in range(tc):
solve()]],
atcoder = [[def solve():
{}
if __name__ == "__main__":
solve()]],
cses = [[{}]],
},
}
local user_overrides = {}
for _, snippet in ipairs(config.snippets or {}) do
user_overrides[snippet.trigger] = snippet
end
for language, template_set in pairs(template_definitions) do
local snippets = {}
local filetype = constants.canonical_filetypes[language]
for contest, template in pairs(template_set) do
local prefixed_trigger = ('cp.nvim/%s.%s'):format(contest, language)
if not user_overrides[prefixed_trigger] then
table.insert(snippets, s(prefixed_trigger, fmt(template, { i(1) })))
end
end
for trigger, snippet in pairs(user_overrides) do
local prefix_match = trigger:match('^cp%.nvim/[^.]+%.(.+)$')
if prefix_match == language then
table.insert(snippets, snippet)
end
end
ls.add_snippets(filetype, snippets)
end
end
return M

View file

@ -1,222 +0,0 @@
---@class cp.ProvisionalState
---@field bufnr integer
---@field platform string
---@field contest_id string
---@field language string
---@field requested_problem_id string?
---@field token integer
---@class cp.IoViewState
---@field output_buf integer
---@field input_buf integer
---@field current_test_index integer?
---@field source_buf integer?
---@class cp.State
---@field get_platform fun(): string?
---@field set_platform fun(platform: string)
---@field get_contest_id fun(): string?
---@field set_contest_id fun(contest_id: string)
---@field get_problem_id fun(): string?
---@field set_problem_id fun(problem_id: string)
---@field get_language fun(): string?
---@field set_language fun(language: string)
---@field get_active_panel fun(): string?
---@field set_active_panel fun(panel: string?)
---@field get_base_name fun(): string?
---@field get_source_file fun(language?: string): string?
---@field get_binary_file fun(): string?
---@field get_input_file fun(): string?
---@field get_output_file fun(): string?
---@field get_expected_file fun(): string?
---@field get_provisional fun(): cp.ProvisionalState?
---@field set_provisional fun(p: cp.ProvisionalState?)
---@field get_saved_session fun(): string?
---@field set_saved_session fun(path: string?)
---@field get_io_view_state fun(): cp.IoViewState?
---@field set_io_view_state fun(s: cp.IoViewState?)
local M = {}
---@type table<string, any>
local state = {
platform = nil,
contest_id = nil,
problem_id = nil,
language = nil,
test_cases = nil,
saved_session = nil,
active_panel = nil,
provisional = nil,
solution_win = nil,
io_view_state = nil,
}
---@return string?
function M.get_platform()
return state.platform
end
---@param platform string
function M.set_platform(platform)
state.platform = platform
end
---@return string?
function M.get_contest_id()
return state.contest_id
end
---@param contest_id string
function M.set_contest_id(contest_id)
state.contest_id = contest_id
end
---@return string?
function M.get_problem_id()
return state.problem_id
end
---@param problem_id string
function M.set_problem_id(problem_id)
state.problem_id = problem_id
end
---@return string?
function M.get_language()
return state.language
end
---@param language string
function M.set_language(language)
state.language = language
end
---@return string?
function M.get_base_name()
local platform, contest_id, problem_id = M.get_platform(), M.get_contest_id(), M.get_problem_id()
if not platform or not contest_id or not problem_id then
return nil
end
local config_module = require('cp.config')
local config = config_module.get_config()
if config.filename then
return config.filename(platform, contest_id, problem_id, config)
else
return config_module.default_filename(contest_id, problem_id)
end
end
---@param language? string
---@return string?
function M.get_source_file(language)
local base_name = M.get_base_name()
if not base_name or not M.get_platform() then
return nil
end
local config = require('cp.config').get_config()
local plat = M.get_platform()
local platform_cfg = config.platforms[plat]
if not platform_cfg then
return nil
end
local target_language = language or state.language or platform_cfg.default_language
local eff = config.runtime.effective[plat] and config.runtime.effective[plat][target_language]
or nil
if not eff or not eff.extension then
return nil
end
return base_name .. '.' .. eff.extension
end
---@return string?
function M.get_binary_file()
local base_name = M.get_base_name()
return base_name and ('build/%s.run'):format(base_name) or nil
end
---@return string?
function M.get_debug_file()
local base_name = M.get_base_name()
return base_name and ('build/%s.dbg'):format(base_name) or nil
end
---@return string?
function M.get_input_file()
local base_name = M.get_base_name()
return base_name and ('io/%s.cpin'):format(base_name) or nil
end
---@return string?
function M.get_output_file()
local base_name = M.get_base_name()
return base_name and ('io/%s.cpout'):format(base_name) or nil
end
---@return string?
function M.get_expected_file()
local base_name = M.get_base_name()
return base_name and ('io/%s.expected'):format(base_name) or nil
end
---@return string?
function M.get_active_panel()
return state.active_panel
end
---@param panel string?
function M.set_active_panel(panel)
state.active_panel = panel
end
---@return cp.ProvisionalState?
function M.get_provisional()
return state.provisional
end
---@param p cp.ProvisionalState?
function M.set_provisional(p)
state.provisional = p
end
---@return integer?
function M.get_solution_win()
if state.solution_win and vim.api.nvim_win_is_valid(state.solution_win) then
return state.solution_win
end
return vim.api.nvim_get_current_win()
end
---@param win integer?
function M.set_solution_win(win)
state.solution_win = win
end
---@return cp.IoViewState?
function M.get_io_view_state()
return state.io_view_state
end
---@param s cp.IoViewState?
function M.set_io_view_state(s)
state.io_view_state = s
end
---@return string?
function M.get_saved_session()
return state.saved_session
end
---@param path string?
function M.set_saved_session(path)
state.saved_session = path
end
M._state = state
return M

281
lua/cp/test.lua Normal file
View file

@ -0,0 +1,281 @@
---@class TestCase
---@field index number
---@field input string
---@field expected string
---@field status "pending"|"pass"|"fail"|"running"|"timeout"
---@field actual string?
---@field time_ms number?
---@field error string?
---@field selected boolean
---@field code number?
---@field ok boolean?
---@field signal string?
---@field timed_out boolean?
---@class TestPanelState
---@field test_cases TestCase[]
---@field current_index number
---@field buffer number?
---@field namespace number?
---@field is_active boolean
---@field saved_layout table?
local M = {}
local constants = require('cp.constants')
local logger = require('cp.log')
---@type TestPanelState
local test_panel_state = {
test_cases = {},
current_index = 1,
buffer = nil,
namespace = nil,
is_active = false,
saved_layout = nil,
}
---@param index number
---@param input string
---@param expected string
---@return TestCase
local function create_test_case(index, input, expected)
return {
index = index,
input = input,
expected = expected,
status = 'pending',
actual = nil,
time_ms = nil,
error = nil,
selected = true,
}
end
---@param platform string
---@param contest_id string
---@param problem_id string?
---@return TestCase[]
local function parse_test_cases_from_cache(platform, contest_id, problem_id)
local cache = require('cp.cache')
cache.load()
local cached_test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if not cached_test_cases or #cached_test_cases == 0 then
return {}
end
local test_cases = {}
for i, test_case in ipairs(cached_test_cases) do
local index = test_case.index or i
local expected = test_case.expected or test_case.output or ''
table.insert(test_cases, create_test_case(index, test_case.input, expected))
end
return test_cases
end
---@param input_file string
---@param expected_file string
---@return TestCase[]
local function parse_test_cases_from_files(input_file, expected_file)
if vim.fn.filereadable(input_file) == 0 or vim.fn.filereadable(expected_file) == 0 then
return {}
end
local base_name = vim.fn.fnamemodify(input_file, ':r')
local test_cases = {}
local i = 1
while true do
local individual_input_file = base_name .. '.' .. i .. '.cpin'
local individual_expected_file = base_name .. '.' .. i .. '.cpout'
if
vim.fn.filereadable(individual_input_file) == 1
and vim.fn.filereadable(individual_expected_file) == 1
then
local input_content = table.concat(vim.fn.readfile(individual_input_file), '\n')
local expected_content = table.concat(vim.fn.readfile(individual_expected_file), '\n')
table.insert(test_cases, create_test_case(i, input_content, expected_content))
i = i + 1
else
break
end
end
if #test_cases == 0 then
local input_content = table.concat(vim.fn.readfile(input_file), '\n')
local expected_content = table.concat(vim.fn.readfile(expected_file), '\n')
return { create_test_case(1, input_content, expected_content) }
end
return test_cases
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@param test_case TestCase
---@return table
local function run_single_test_case(ctx, contest_config, test_case)
local language = vim.fn.fnamemodify(ctx.source_file, ':e')
local language_name = constants.filetype_to_language[language] or contest_config.default_language
local language_config = contest_config[language_name]
if not language_config then
return {
status = 'fail',
actual = '',
error = 'No language configuration',
time_ms = 0,
}
end
local function substitute_template(cmd_template, substitutions)
local result = {}
for _, arg in ipairs(cmd_template) do
local substituted = arg
for key, value in pairs(substitutions) do
substituted = substituted:gsub('{' .. key .. '}', value)
end
table.insert(result, substituted)
end
return result
end
local function build_command(cmd_template, executable, substitutions)
local cmd = substitute_template(cmd_template, substitutions)
if executable then
table.insert(cmd, 1, executable)
end
return cmd
end
local substitutions = {
source = ctx.source_file,
binary = ctx.binary_file,
version = tostring(language_config.version or ''),
}
if language_config.compile and vim.fn.filereadable(ctx.binary_file) == 0 then
logger.log('binary not found, compiling first...')
local compile_cmd = substitute_template(language_config.compile, substitutions)
local compile_result = vim.system(compile_cmd, { text = true }):wait()
if compile_result.code ~= 0 then
return {
status = 'fail',
actual = '',
error = 'Compilation failed: ' .. (compile_result.stderr or 'Unknown error'),
time_ms = 0,
}
end
end
local run_cmd = build_command(language_config.run, language_config.executable, substitutions)
local stdin_content = test_case.input .. '\n'
local start_time = vim.uv.hrtime()
local result = vim
.system(run_cmd, {
stdin = stdin_content,
timeout = contest_config.timeout_ms or 2000,
text = true,
})
:wait()
local execution_time = (vim.uv.hrtime() - start_time) / 1000000
local actual_output = (result.stdout or ''):gsub('\n$', '')
local expected_output = test_case.expected:gsub('\n$', '')
local ok = actual_output == expected_output
local status
local timed_out = result.code == 143 or result.code == 124
if timed_out then
status = 'timeout'
elseif result.code == 0 and ok then
status = 'pass'
else
status = 'fail'
end
local signal = nil
if result.code >= 128 then
signal = constants.signal_codes[result.code]
end
return {
status = status,
actual = actual_output,
error = result.code ~= 0 and result.stderr or nil,
time_ms = execution_time,
code = result.code,
ok = ok,
signal = signal,
timed_out = timed_out,
}
end
---@param ctx ProblemContext
---@param state table
---@return boolean
function M.load_test_cases(ctx, state)
local test_cases = parse_test_cases_from_cache(state.platform, state.contest_id, state.problem_id)
if #test_cases == 0 then
test_cases = parse_test_cases_from_files(ctx.input_file, ctx.expected_file)
end
test_panel_state.test_cases = test_cases
test_panel_state.current_index = 1
logger.log(('loaded %d test case(s)'):format(#test_cases))
return #test_cases > 0
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@param index number
---@return boolean
function M.run_test_case(ctx, contest_config, index)
local test_case = test_panel_state.test_cases[index]
if not test_case then
return false
end
logger.log(('running test case %d'):format(index))
test_case.status = 'running'
local result = run_single_test_case(ctx, contest_config, test_case)
test_case.status = result.status
test_case.actual = result.actual
test_case.error = result.error
test_case.time_ms = result.time_ms
test_case.code = result.code
test_case.ok = result.ok
test_case.signal = result.signal
test_case.timed_out = result.timed_out
return true
end
---@param ctx ProblemContext
---@param contest_config ContestConfig
---@return TestCase[]
function M.run_all_test_cases(ctx, contest_config)
local results = {}
for i, _ in ipairs(test_panel_state.test_cases) do
M.run_test_case(ctx, contest_config, i)
table.insert(results, test_panel_state.test_cases[i])
end
return results
end
---@return TestPanelState
function M.get_test_panel_state()
return test_panel_state
end
return M

View file

@ -1,371 +0,0 @@
---@class AnsiParseResult
---@field lines string[]
---@field highlights Highlight[]
---@class Highlight
---@field line number
---@field col_start number
---@field col_end number
---@field highlight_group string
local M = {}
local dyn_hl_cache = {}
local ANSI_TERMINAL_COLOR_CODE_FALLBACK = {
[0] = '#000000',
[1] = '#800000',
[2] = '#008000',
[3] = '#808000',
[4] = '#000080',
[5] = '#800080',
[6] = '#008080',
[7] = '#c0c0c0',
[8] = '#808080',
[9] = '#ff0000',
[10] = '#00ff00',
[11] = '#ffff00',
[12] = '#0000ff',
[13] = '#ff00ff',
[14] = '#00ffff',
[15] = '#ffffff',
}
local function xterm_to_hex(n)
if n >= 0 and n <= 15 then
local key = 'terminal_color_' .. n
return vim.g[key] or ANSI_TERMINAL_COLOR_CODE_FALLBACK[n]
end
if n >= 16 and n <= 231 then
local c = n - 16
local r = math.floor(c / 36) % 6
local g = math.floor(c / 6) % 6
local b = c % 6
local function level(x)
return x == 0 and 0 or 55 + 40 * x
end
return ('#%02x%02x%02x'):format(level(r), level(g), level(b))
end
local l = 8 + 10 * (n - 232)
return ('#%02x%02x%02x'):format(l, l, l)
end
---@param s string|table
---@return string
function M.bytes_to_string(s)
if type(s) == 'string' then
return s
end
return table.concat(vim.tbl_map(string.char, s))
end
---@param fg table|nil
---@param bold boolean
---@param italic boolean
---@return string|nil
local function ensure_hl_for(fg, bold, italic)
if not fg and not bold and not italic then
return nil
end
local base = 'CpAnsi'
local suffix
local opts = {}
if fg and fg.kind == 'named' then
suffix = fg.name
elseif fg and fg.kind == 'xterm' then
suffix = ('X%03d'):format(fg.idx)
opts.fg = xterm_to_hex(fg.idx) or 'NONE'
elseif fg and fg.kind == 'rgb' then
suffix = ('Rgb%02x%02x%02x'):format(fg.r, fg.g, fg.b)
opts.fg = ('#%02x%02x%02x'):format(fg.r, fg.g, fg.b)
end
local parts = { base }
if bold then
table.insert(parts, 'Bold')
end
if italic then
table.insert(parts, 'Italic')
end
if suffix then
table.insert(parts, suffix)
end
local name = table.concat(parts)
if not dyn_hl_cache[name] then
if bold then
opts.bold = true
end
if italic then
opts.italic = true
end
vim.api.nvim_set_hl(0, name, opts)
dyn_hl_cache[name] = true
end
return name
end
---@param text string
---@return AnsiParseResult
function M.parse_ansi_text(text)
local clean_text = text:gsub('\027%[[%d;]*[a-zA-Z]', '')
local lines = vim.split(clean_text, '\n', { plain = true })
local highlights = {}
local line_num = 0
local col_pos = 0
local ansi_state = {
bold = false,
italic = false,
foreground = nil,
}
local function get_highlight_group()
return ensure_hl_for(ansi_state.foreground, ansi_state.bold, ansi_state.italic)
end
local function apply_highlight(start_line, start_col, end_col)
local hl_group = get_highlight_group()
if hl_group then
table.insert(highlights, {
line = start_line,
col_start = start_col,
col_end = end_col,
highlight_group = hl_group,
})
end
end
local i = 1
while i <= #text do
local ansi_start, ansi_end, code, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i)
if ansi_start then
if ansi_start > i then
local segment = text:sub(i, ansi_start - 1)
local start_line = line_num
local start_col = col_pos
for char in segment:gmatch('.') do
if char == '\n' then
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
line_num = line_num + 1
start_line = line_num
col_pos = 0
start_col = 0
else
col_pos = col_pos + 1
end
end
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
end
if cmd == 'm' then
M.update_ansi_state(ansi_state, code)
end
i = ansi_end + 1
else
local segment = text:sub(i)
if segment ~= '' then
local start_line = line_num
local start_col = col_pos
for char in segment:gmatch('.') do
if char == '\n' then
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
line_num = line_num + 1
start_line = line_num
col_pos = 0
start_col = 0
else
col_pos = col_pos + 1
end
end
if col_pos > start_col then
apply_highlight(start_line, start_col, col_pos)
end
end
break
end
end
return {
lines = lines,
highlights = highlights,
}
end
---@param ansi_state table
---@param code_string string
---@return nil
function M.update_ansi_state(ansi_state, code_string)
if code_string == '' or code_string == '0' then
ansi_state.bold = false
ansi_state.italic = false
ansi_state.foreground = nil
return
end
local codes = vim.split(code_string, ';', { plain = true })
local idx = 1
while idx <= #codes do
local num = tonumber(codes[idx])
if num == 1 then
ansi_state.bold = true
elseif num == 3 then
ansi_state.italic = true
elseif num == 22 then
ansi_state.bold = false
elseif num == 23 then
ansi_state.italic = false
elseif num and num >= 30 and num <= 37 then
local colors = { 'Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White' }
ansi_state.foreground = { kind = 'named', name = colors[num - 29] }
elseif num and num >= 90 and num <= 97 then
local colors = {
'BrightBlack',
'BrightRed',
'BrightGreen',
'BrightYellow',
'BrightBlue',
'BrightMagenta',
'BrightCyan',
'BrightWhite',
}
ansi_state.foreground = { kind = 'named', name = colors[num - 89] }
elseif num == 39 then
ansi_state.foreground = nil
elseif num == 38 or num == 48 then
local is_fg = (num == 38)
local mode = tonumber(codes[idx + 1] or '')
if mode == 5 and codes[idx + 2] then
local pal = tonumber(codes[idx + 2]) or 0
if is_fg then
ansi_state.foreground = { kind = 'xterm', idx = pal }
end
idx = idx + 2
elseif mode == 2 and codes[idx + 2] and codes[idx + 3] and codes[idx + 4] then
local r = tonumber(codes[idx + 2]) or 0
local g = tonumber(codes[idx + 3]) or 0
local b = tonumber(codes[idx + 4]) or 0
if is_fg then
ansi_state.foreground = { kind = 'rgb', r = r, g = g, b = b }
end
idx = idx + 4
end
end
idx = idx + 1
end
end
---@return nil
function M.setup_highlight_groups()
local color_map = {
Black = vim.g.terminal_color_0 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[0],
Red = vim.g.terminal_color_1 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[1],
Green = vim.g.terminal_color_2 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[2],
Yellow = vim.g.terminal_color_3 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[3],
Blue = vim.g.terminal_color_4 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[4],
Magenta = vim.g.terminal_color_5 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[5],
Cyan = vim.g.terminal_color_6 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[6],
White = vim.g.terminal_color_7 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[7],
BrightBlack = vim.g.terminal_color_8 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[8],
BrightRed = vim.g.terminal_color_9 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[9],
BrightGreen = vim.g.terminal_color_10 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[10],
BrightYellow = vim.g.terminal_color_11 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[11],
BrightBlue = vim.g.terminal_color_12 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[12],
BrightMagenta = vim.g.terminal_color_13 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[13],
BrightCyan = vim.g.terminal_color_14 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[14],
BrightWhite = vim.g.terminal_color_15 or ANSI_TERMINAL_COLOR_CODE_FALLBACK[15],
}
local combinations = {
{ bold = false, italic = false },
{ bold = true, italic = false },
{ bold = false, italic = true },
{ bold = true, italic = true },
}
for _, combo in ipairs(combinations) do
for color_name, terminal_color in pairs(color_map) do
local parts = { 'CpAnsi' }
local opts = { fg = terminal_color or 'NONE' }
if combo.bold then
table.insert(parts, 'Bold')
opts.bold = true
end
if combo.italic then
table.insert(parts, 'Italic')
opts.italic = true
end
table.insert(parts, color_name)
local hl_name = table.concat(parts)
vim.api.nvim_set_hl(0, hl_name, opts)
end
end
vim.api.nvim_set_hl(0, 'CpAnsiBold', { bold = true })
vim.api.nvim_set_hl(0, 'CpAnsiItalic', { italic = true })
vim.api.nvim_set_hl(0, 'CpAnsiBoldItalic', { bold = true, italic = true })
for _, combo in ipairs(combinations) do
for color_name, _ in pairs(color_map) do
local parts = { 'CpAnsi' }
if combo.bold then
table.insert(parts, 'Bold')
end
if combo.italic then
table.insert(parts, 'Italic')
end
table.insert(parts, color_name)
local hl_name = table.concat(parts)
dyn_hl_cache[hl_name] = true
end
end
dyn_hl_cache['CpAnsiBold'] = true
dyn_hl_cache['CpAnsiItalic'] = true
dyn_hl_cache['CpAnsiBoldItalic'] = true
end
---@param text string
---@return string[]
function M.debug_ansi_tokens(text)
local out = {}
local i = 1
while true do
local s, e, codes, cmd = text:find('\027%[([%d;]*)([a-zA-Z])', i)
if not s then
break
end
table.insert(out, ('ESC[%s%s'):format(codes, cmd))
i = e + 1
end
return out
end
---@param s string
---@return string
function M.hex_dump(s)
local t = {}
for i = 1, #s do
t[#t + 1] = ('%02X'):format(s:byte(i))
end
return table.concat(t, ' ')
end
return M

View file

@ -1,204 +0,0 @@
---@class DiffResult
---@field content string[]
---@field highlights Highlight[]?
---@field raw_diff string?
---@class DiffBackend
---@field name string
---@field render fun(expected: string, actual: string): DiffResult
local M = {}
---@type DiffBackend
local vim_backend = {
name = 'vim',
render = function(_, actual)
local actual_lines = vim.split(actual, '\n', { plain = true })
return {
content = actual_lines,
highlights = nil,
}
end,
}
---@type DiffBackend
local none_backend = {
name = 'none',
render = function(expected, actual)
local expected_lines = vim.split(expected, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual, '\n', { plain = true })
return {
content = { expected = expected_lines, actual = actual_lines },
highlights = {},
}
end,
}
---@type DiffBackend
local git_backend = {
name = 'git',
render = function(expected, actual)
local tmp_expected = vim.fn.tempname()
local tmp_actual = vim.fn.tempname()
vim.fn.writefile(vim.split(expected, '\n', { plain = true }), tmp_expected)
vim.fn.writefile(vim.split(actual, '\n', { plain = true }), tmp_actual)
local cmd = {
'git',
'diff',
'--no-index',
'--word-diff=plain',
'--word-diff-regex=.',
'--no-prefix',
tmp_expected,
tmp_actual,
}
local result = vim.system(cmd, { text = true }):wait()
vim.fn.delete(tmp_expected)
vim.fn.delete(tmp_actual)
if result.code == 0 then
return {
content = vim.split(actual, '\n', { plain = true }),
highlights = {},
}
else
local diff_content = result.stdout or ''
local lines = {}
local highlights = {}
local line_num = 0
for line in diff_content:gmatch('[^\n]*') do
if
line:match('^[%s%+%-]')
or (not line:match('^[@%-+]') and not line:match('^index') and not line:match('^diff'))
then
local clean_line = line
if line:match('^[%+%-]') then
clean_line = line:sub(2)
end
local col_pos = 0
local processed_line = ''
local i = 1
while i <= #clean_line do
local removed_start, removed_end = clean_line:find('%[%-[^%-]*%-]', i)
local added_start, added_end = clean_line:find('{%+[^%+]*%+}', i)
local next_marker_start = nil
local marker_type = nil
if removed_start and (not added_start or removed_start < added_start) then
next_marker_start = removed_start
marker_type = 'removed'
elseif added_start then
next_marker_start = added_start
marker_type = 'added'
end
if next_marker_start then
if next_marker_start > i then
local before_text = clean_line:sub(i, next_marker_start - 1)
processed_line = processed_line .. before_text
col_pos = col_pos + #before_text
end
local marker_end = (marker_type == 'removed') and removed_end or added_end
local marker_text = clean_line:sub(next_marker_start, marker_end)
local content_text
if marker_type == 'removed' then
content_text = marker_text:sub(3, -3)
table.insert(highlights, {
line = line_num,
col_start = col_pos,
col_end = col_pos + #content_text,
highlight_group = 'DiffDelete',
})
else
content_text = marker_text:sub(3, -3)
table.insert(highlights, {
line = line_num,
col_start = col_pos,
col_end = col_pos + #content_text,
highlight_group = 'DiffAdd',
})
end
processed_line = processed_line .. content_text
col_pos = col_pos + #content_text
i = marker_end + 1
else
local rest = clean_line:sub(i)
processed_line = processed_line .. rest
break
end
end
table.insert(lines, processed_line)
line_num = line_num + 1
end
end
return {
content = lines,
highlights = highlights,
raw_diff = diff_content,
}
end
end,
}
---@type table<string, DiffBackend>
local backends = {
none = none_backend,
vim = vim_backend,
git = git_backend,
}
---@return string[]
function M.get_available_backends()
return vim.tbl_keys(backends)
end
---@param name string
---@return DiffBackend?
function M.get_backend(name)
return backends[name]
end
---@return boolean
function M.is_git_available()
local result = vim.system({ 'git', '--version' }, { text = true }):wait()
return result.code == 0
end
---@param preferred_backend? string
---@return DiffBackend
function M.get_best_backend(preferred_backend)
if preferred_backend and backends[preferred_backend] then
if preferred_backend == 'git' and not M.is_git_available() then
return backends.vim
end
return backends[preferred_backend]
end
return backends.vim
end
---@param expected string
---@param actual string
---@param backend_name? string
---@return DiffResult
function M.render_diff(expected, actual, backend_name)
local backend = M.get_best_backend(backend_name)
return backend.render(expected, actual)
end
return M

View file

@ -1,464 +0,0 @@
local M = {}
local cache = require('cp.cache')
local config_module = require('cp.config')
local helpers = require('cp.helpers')
local logger = require('cp.log')
local state = require('cp.state')
local utils = require('cp.utils')
---@class TestBufferPair
---@field input_buf integer
---@field expected_buf integer
---@field input_win integer
---@field expected_win integer
---@class EditState
---@field test_buffers TestBufferPair[]
---@field test_cases TestCase[]
---@field constraints ProblemConstraints?
---@type EditState?
local edit_state = nil
local setup_keybindings
---@param bufnr integer
---@return integer? test_index
local function get_current_test_index(bufnr)
if not edit_state then
return nil
end
for i, pair in ipairs(edit_state.test_buffers) do
if pair.input_buf == bufnr or pair.expected_buf == bufnr then
return i
end
end
return nil
end
---@param index integer
local function jump_to_test(index)
if not edit_state then
return
end
local pair = edit_state.test_buffers[index]
if pair and vim.api.nvim_win_is_valid(pair.input_win) then
vim.api.nvim_set_current_win(pair.input_win)
end
end
---@param delta integer
local function navigate_test(delta)
local current_buf = vim.api.nvim_get_current_buf()
local current_index = get_current_test_index(current_buf)
if not current_index or not edit_state then
return
end
local new_index = current_index + delta
if new_index < 1 or new_index > #edit_state.test_buffers then
return
end
jump_to_test(new_index)
end
---@param test_index integer
local function load_test_into_buffer(test_index)
if not edit_state then
return
end
local tc = edit_state.test_cases[test_index]
local pair = edit_state.test_buffers[test_index]
if not tc or not pair then
return
end
local input_lines = vim.split(tc.input or '', '\n', { plain = true, trimempty = false })
vim.api.nvim_buf_set_lines(pair.input_buf, 0, -1, false, input_lines)
local expected_lines = vim.split(tc.expected or '', '\n', { plain = true, trimempty = false })
vim.api.nvim_buf_set_lines(pair.expected_buf, 0, -1, false, expected_lines)
vim.api.nvim_buf_set_name(pair.input_buf, string.format('cp://test-%d-input', test_index))
vim.api.nvim_buf_set_name(pair.expected_buf, string.format('cp://test-%d-expected', test_index))
end
local function delete_current_test()
if not edit_state then
return
end
if #edit_state.test_buffers == 1 then
logger.log('Problems must have at least one test case.', vim.log.levels.ERROR)
return
end
local current_buf = vim.api.nvim_get_current_buf()
local current_index = get_current_test_index(current_buf)
if not current_index then
return
end
local pair = edit_state.test_buffers[current_index]
if vim.api.nvim_win_is_valid(pair.input_win) then
vim.api.nvim_win_close(pair.input_win, true)
end
if vim.api.nvim_win_is_valid(pair.expected_win) then
vim.api.nvim_win_close(pair.expected_win, true)
end
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
table.remove(edit_state.test_buffers, current_index)
table.remove(edit_state.test_cases, current_index)
for i = current_index, #edit_state.test_buffers do
load_test_into_buffer(i)
end
local next_index = math.min(current_index, #edit_state.test_buffers)
jump_to_test(next_index)
logger.log(('Deleted test %d'):format(current_index))
end
local function add_new_test()
if not edit_state then
return
end
local last_pair = edit_state.test_buffers[#edit_state.test_buffers]
if not last_pair or not vim.api.nvim_win_is_valid(last_pair.input_win) then
return
end
vim.api.nvim_set_current_win(last_pair.input_win)
vim.cmd.vsplit()
local input_win = vim.api.nvim_get_current_win()
local input_buf = utils.create_buffer_with_options()
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].buflisted = false
helpers.clearcol(input_buf)
vim.api.nvim_set_current_win(last_pair.expected_win)
vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
local expected_buf = utils.create_buffer_with_options()
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].buflisted = false
helpers.clearcol(expected_buf)
local new_index = #edit_state.test_buffers + 1
local new_pair = {
input_buf = input_buf,
expected_buf = expected_buf,
input_win = input_win,
expected_win = expected_win,
}
table.insert(edit_state.test_buffers, new_pair)
table.insert(edit_state.test_cases, { index = new_index, input = '', expected = '' })
setup_keybindings(input_buf)
setup_keybindings(expected_buf)
load_test_into_buffer(new_index)
vim.api.nvim_set_current_win(input_win)
logger.log(('Added test %d'):format(new_index))
end
---@param buf integer
setup_keybindings = function(buf)
local config = config_module.get_config()
local keys = config.ui.edit
if keys.save_and_exit_key then
vim.keymap.set('n', keys.save_and_exit_key, function()
M.toggle_edit()
end, { buffer = buf, silent = true, desc = 'Save and exit test editor' })
end
if keys.next_test_key then
vim.keymap.set('n', keys.next_test_key, function()
navigate_test(1)
end, { buffer = buf, silent = true, desc = 'Next test' })
end
if keys.prev_test_key then
vim.keymap.set('n', keys.prev_test_key, function()
navigate_test(-1)
end, { buffer = buf, silent = true, desc = 'Previous test' })
end
if keys.delete_test_key then
vim.keymap.set(
'n',
keys.delete_test_key,
delete_current_test,
{ buffer = buf, silent = true, desc = 'Delete test' }
)
end
if keys.add_test_key then
vim.keymap.set(
'n',
keys.add_test_key,
add_new_test,
{ buffer = buf, silent = true, desc = 'Add test' }
)
end
local augroup = vim.api.nvim_create_augroup('cp_edit_guard', { clear = false })
vim.api.nvim_create_autocmd({ 'BufDelete', 'BufWipeout' }, {
group = augroup,
buffer = buf,
callback = function()
vim.schedule(function()
if not edit_state then
return
end
local is_tracked = false
for _, pair in ipairs(edit_state.test_buffers) do
if pair.input_buf == buf or pair.expected_buf == buf then
is_tracked = true
break
end
end
if is_tracked then
logger.log('Test buffer closed unexpectedly. Exiting editor.', vim.log.levels.WARN)
M.toggle_edit()
end
end)
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()
edit_state = nil
pcall(vim.api.nvim_clear_autocmds, { group = 'cp_edit_guard' })
local saved = state.get_saved_session()
if saved then
vim.fn.delete(saved)
state.set_saved_session(nil)
end
vim.cmd.only({ mods = { silent = true } })
local source_file = state.get_source_file()
if source_file and vim.fn.filereadable(source_file) == 1 then
vim.cmd.edit(source_file)
end
local views = require('cp.ui.views')
views.ensure_io_view()
logger.log('Closed test editor')
return
end
local platform, contest_id, problem_id =
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 <platform> <contest> first.', vim.log.levels.ERROR)
return
end
cache.load()
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)
return
end
local timeout_ms, memory_mb = cache.get_constraints(platform, contest_id, problem_id)
local constraints = (timeout_ms and memory_mb)
and { timeout_ms = timeout_ms, memory_mb = memory_mb }
or nil
local target_index = test_index or 1
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
)
return
end
local io_view_state = state.get_io_view_state()
if io_view_state then
if io_view_state.output_buf and vim.api.nvim_buf_is_valid(io_view_state.output_buf) then
vim.api.nvim_buf_delete(io_view_state.output_buf, { force = true })
end
if io_view_state.input_buf and vim.api.nvim_buf_is_valid(io_view_state.input_buf) then
vim.api.nvim_buf_delete(io_view_state.input_buf, { force = true })
end
state.set_io_view_state(nil)
end
local session_file = vim.fn.tempname()
state.set_saved_session(session_file)
-- selene: allow(mixed_table)
vim.cmd.mksession({ session_file, bang = true })
vim.cmd.only({ mods = { silent = true } })
local test_buffers = {}
local num_tests = #test_cases
for _ = 1, num_tests - 1 do
vim.cmd.vsplit()
end
vim.cmd('1 wincmd w')
for col = 1, num_tests do
vim.cmd.split()
vim.cmd.wincmd('k')
local input_win = vim.api.nvim_get_current_win()
local input_buf = utils.create_buffer_with_options()
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].buflisted = false
helpers.clearcol(input_buf)
vim.cmd.wincmd('j')
local expected_win = vim.api.nvim_get_current_win()
local expected_buf = utils.create_buffer_with_options()
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].buflisted = false
helpers.clearcol(expected_buf)
test_buffers[col] = {
input_buf = input_buf,
expected_buf = expected_buf,
input_win = input_win,
expected_win = expected_win,
}
vim.cmd.wincmd('k')
vim.cmd.wincmd('l')
end
edit_state = {
test_buffers = test_buffers,
test_cases = test_cases,
constraints = constraints,
}
for i = 1, num_tests do
load_test_into_buffer(i)
end
for _, pair in ipairs(test_buffers) do
setup_keybindings(pair.input_buf)
setup_keybindings(pair.expected_buf)
end
if
test_buffers[target_index]
and vim.api.nvim_win_is_valid(test_buffers[target_index].input_win)
then
vim.api.nvim_set_current_win(test_buffers[target_index].input_win)
end
logger.log(('Editing %d test cases'):format(num_tests))
end
return M

View file

@ -1,156 +0,0 @@
---@class DiffHighlight
---@field line number
---@field col_start number
---@field col_end number
---@field highlight_group string
---@class ParsedDiff
---@field content string[]
---@field highlights DiffHighlight[]
local M = {}
---@param text string Raw git diff output line
---@return string cleaned_text, DiffHighlight[]
local function parse_diff_line(text)
local result_text = ''
local highlights = {}
local pos = 1
while pos <= #text do
local removed_start, removed_end, removed_content = text:find('%[%-(.-)%-%]', pos)
if removed_start and removed_start == pos then
local highlight_start = #result_text
result_text = result_text .. removed_content
table.insert(highlights, {
line = 0,
col_start = highlight_start,
col_end = #result_text,
highlight_group = 'DiffDelete',
})
pos = removed_end + 1
else
local added_start, added_end, added_content = text:find('{%+(.-)%+}', pos)
if added_start and added_start == pos then
local highlight_start = #result_text
result_text = result_text .. added_content
table.insert(highlights, {
line = 0,
col_start = highlight_start,
col_end = #result_text,
highlight_group = 'DiffAdd',
})
pos = added_end + 1
else
result_text = result_text .. text:sub(pos, pos)
pos = pos + 1
end
end
end
return result_text, highlights
end
---@param diff_output string
---@return ParsedDiff
function M.parse_git_diff(diff_output)
if diff_output == '' then
return { content = {}, highlights = {} }
end
local lines = vim.split(diff_output, '\n', { plain = true })
local content_lines = {}
local all_highlights = {}
local content_started = false
for _, line in ipairs(lines) do
if
content_started
or (
not line:match('^@@')
and not line:match('^%+%+%+')
and not line:match('^%-%-%-')
and not line:match('^index')
and not line:match('^diff %-%-git')
)
then
content_started = true
if line:match('^%+') then
local clean_line = line:sub(2)
local parsed_line, line_highlights = parse_diff_line(clean_line)
table.insert(content_lines, parsed_line)
local line_num = #content_lines
for _, highlight in ipairs(line_highlights) do
highlight.line = line_num - 1
table.insert(all_highlights, highlight)
end
elseif not line:match('^%-') and not line:match('^\\') then
local clean_line = line:match('^%s') and line:sub(2) or line
local parsed_line, line_highlights = parse_diff_line(clean_line)
if parsed_line ~= '' then
table.insert(content_lines, parsed_line)
local line_num = #content_lines
for _, highlight in ipairs(line_highlights) do
highlight.line = line_num - 1
table.insert(all_highlights, highlight)
end
end
end
end
end
return {
content = content_lines,
highlights = all_highlights,
}
end
---@param bufnr number
---@param highlights DiffHighlight[]
---@param namespace number
function M.apply_highlights(bufnr, highlights, namespace)
vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
for _, highlight in ipairs(highlights) do
if highlight.col_start < highlight.col_end then
vim.api.nvim_buf_set_extmark(bufnr, namespace, highlight.line, highlight.col_start, {
end_col = highlight.col_end,
hl_group = highlight.highlight_group,
priority = 100,
})
end
end
end
---@return number
function M.create_namespace()
return vim.api.nvim_create_namespace('cp_diff_highlights')
end
---@param bufnr number
---@param diff_output string
---@param namespace number
---@return string[] content_lines
function M.parse_and_apply_diff(bufnr, diff_output, namespace)
local parsed = M.parse_git_diff(diff_output)
local was_modifiable = vim.api.nvim_get_option_value('modifiable', { buf = bufnr })
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr })
vim.api.nvim_set_option_value('readonly', false, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, parsed.content)
vim.api.nvim_set_option_value('modifiable', was_modifiable, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr })
M.apply_highlights(bufnr, parsed.highlights, namespace)
return parsed.content
end
return M

View file

@ -1,320 +0,0 @@
local M = {}
local helpers = require('cp.helpers')
local utils = require('cp.utils')
M.DIFF_MODES = {
['side-by-side'] = 'side-by-side',
vim = 'vim',
git = 'git',
}
local function create_side_by_side_layout(parent_win, expected_content, actual_content)
local expected_buf = utils.create_buffer_with_options()
local actual_buf = utils.create_buffer_with_options()
helpers.clearcol(expected_buf)
helpers.clearcol(actual_buf)
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local actual_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(actual_win, actual_buf)
vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
local label = M.DIFF_MODES['side-by-side']
vim.api.nvim_set_option_value(
'winbar',
('expected (diff: %s)'):format(label),
{ win = expected_win }
)
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(expected_buf, expected_lines, {})
utils.update_buffer_content(actual_buf, actual_lines, {})
return {
buffers = { expected_buf, actual_buf },
windows = { expected_win, actual_win },
mode = 'side-by-side',
cleanup = function()
pcall(vim.api.nvim_win_close, expected_win, true)
pcall(vim.api.nvim_win_close, actual_win, true)
pcall(vim.api.nvim_buf_delete, expected_buf, { force = true })
pcall(vim.api.nvim_buf_delete, actual_buf, { force = true })
end,
}
end
local function create_vim_diff_layout(parent_win, expected_content, actual_content)
local expected_buf = utils.create_buffer_with_options()
local actual_buf = utils.create_buffer_with_options()
helpers.clearcol(expected_buf)
helpers.clearcol(actual_buf)
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local actual_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(actual_win, actual_buf)
vim.cmd.vsplit()
local expected_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(expected_win, expected_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = expected_buf })
vim.api.nvim_set_option_value('filetype', 'cp', { buf = actual_buf })
local label = M.DIFF_MODES.vim
vim.api.nvim_set_option_value(
'winbar',
('expected (diff: %s)'):format(label),
{ win = expected_win }
)
vim.api.nvim_set_option_value('winbar', ('actual (diff: %s)'):format(label), { win = actual_win })
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(expected_buf, expected_lines, {})
utils.update_buffer_content(actual_buf, actual_lines, {})
vim.api.nvim_set_option_value('diff', true, { win = expected_win })
vim.api.nvim_set_option_value('diff', true, { win = actual_win })
vim.api.nvim_win_call(expected_win, function()
vim.cmd.diffthis()
end)
vim.api.nvim_win_call(actual_win, function()
vim.cmd.diffthis()
end)
vim.api.nvim_set_option_value('foldcolumn', '0', { win = expected_win })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = actual_win })
return {
buffers = { expected_buf, actual_buf },
windows = { expected_win, actual_win },
mode = 'vim',
cleanup = function()
pcall(vim.api.nvim_win_close, expected_win, true)
pcall(vim.api.nvim_win_close, actual_win, true)
pcall(vim.api.nvim_buf_delete, expected_buf, { force = true })
pcall(vim.api.nvim_buf_delete, actual_buf, { force = true })
end,
}
end
local function create_git_diff_layout(parent_win, expected_content, actual_content)
local diff_buf = utils.create_buffer_with_options()
helpers.clearcol(diff_buf)
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local diff_win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(diff_win, diff_buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = diff_buf })
local label = M.DIFF_MODES.git
vim.api.nvim_set_option_value('winbar', ('diff: %s'):format(label), { win = diff_win })
local diff_backend = require('cp.ui.diff')
local backend = diff_backend.get_best_backend('git')
local diff_result = backend.render(expected_content, actual_content)
local highlight = require('cp.ui.highlight')
local diff_namespace = highlight.create_namespace()
if diff_result.raw_diff and diff_result.raw_diff ~= '' then
highlight.parse_and_apply_diff(diff_buf, diff_result.raw_diff, diff_namespace)
else
local lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(diff_buf, lines, {})
end
return {
buffers = { diff_buf },
windows = { diff_win },
mode = 'git',
cleanup = function()
pcall(vim.api.nvim_win_close, diff_win, true)
pcall(vim.api.nvim_buf_delete, diff_buf, { force = true })
end,
}
end
local function create_single_layout(parent_win, content)
local buf = utils.create_buffer_with_options()
local lines = vim.split(content, '\n', { plain = true })
utils.update_buffer_content(buf, lines, {})
vim.api.nvim_set_current_win(parent_win)
vim.cmd.split()
vim.cmd.resize(math.floor(vim.o.lines * 0.35))
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, buf)
vim.api.nvim_set_option_value('filetype', 'cp', { buf = buf })
return {
buffers = { buf },
windows = { win },
mode = 'single',
cleanup = function()
pcall(vim.api.nvim_win_close, win, true)
pcall(vim.api.nvim_buf_delete, buf, { force = true })
end,
}
end
function M.create_diff_layout(mode, parent_win, expected_content, actual_content)
if mode == 'single' then
return create_single_layout(parent_win, actual_content)
elseif mode == 'side-by-side' then
return create_side_by_side_layout(parent_win, expected_content, actual_content)
elseif mode == 'git' then
return create_git_diff_layout(parent_win, expected_content, actual_content)
elseif mode == 'vim' then
return create_vim_diff_layout(parent_win, expected_content, actual_content)
else
return create_side_by_side_layout(parent_win, expected_content, actual_content)
end
end
function M.update_diff_panes(
current_diff_layout,
current_mode,
main_win,
run,
config,
setup_keybindings_for_buffer
)
local test_state = run.get_panel_state()
local current_test = test_state.test_cases[test_state.current_index]
if not current_test then
return current_diff_layout, current_mode
end
local expected_content = current_test.expected or ''
local actual_content = current_test.actual or '(not run yet)'
local actual_highlights = current_test.actual_highlights or {}
local is_compilation_failure = current_test.error
and current_test.error:match('Compilation failed')
local should_show_diff = current_test.status == 'fail'
and current_test.actual
and not is_compilation_failure
if not should_show_diff then
expected_content = expected_content
actual_content = actual_content
end
local default_mode = config.ui.panel.diff_modes[1]
local desired_mode = is_compilation_failure and 'single' or (current_mode or default_mode)
local highlight = require('cp.ui.highlight')
local diff_namespace = highlight.create_namespace()
local ansi_namespace = vim.api.nvim_create_namespace('cp_ansi_highlights')
if current_diff_layout and current_diff_layout.mode ~= desired_mode then
local saved_pos = vim.api.nvim_win_get_cursor(0)
current_diff_layout.cleanup()
current_diff_layout = nil
current_mode = nil
current_diff_layout =
M.create_diff_layout(desired_mode, main_win, expected_content, actual_content)
current_mode = desired_mode
for _, buf in ipairs(current_diff_layout.buffers) do
setup_keybindings_for_buffer(buf)
end
pcall(vim.api.nvim_win_set_cursor, 0, saved_pos)
return current_diff_layout, current_mode
end
if not current_diff_layout then
current_diff_layout =
M.create_diff_layout(desired_mode, main_win, expected_content, actual_content)
current_mode = desired_mode
for _, buf in ipairs(current_diff_layout.buffers) do
setup_keybindings_for_buffer(buf)
end
else
if desired_mode == 'single' then
local lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(
current_diff_layout.buffers[1],
lines,
actual_highlights,
ansi_namespace
)
elseif desired_mode == 'git' then
local diff_backend = require('cp.ui.diff')
local backend = diff_backend.get_best_backend('git')
local diff_result = backend.render(expected_content, actual_content)
if diff_result.raw_diff and diff_result.raw_diff ~= '' then
highlight.parse_and_apply_diff(
current_diff_layout.buffers[1],
diff_result.raw_diff,
diff_namespace
)
else
local lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(
current_diff_layout.buffers[1],
lines,
actual_highlights,
ansi_namespace
)
end
elseif desired_mode == 'side-by-side' then
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})
utils.update_buffer_content(
current_diff_layout.buffers[2],
actual_lines,
actual_highlights,
ansi_namespace
)
else
local expected_lines = vim.split(expected_content, '\n', { plain = true, trimempty = true })
local actual_lines = vim.split(actual_content, '\n', { plain = true })
utils.update_buffer_content(current_diff_layout.buffers[1], expected_lines, {})
utils.update_buffer_content(
current_diff_layout.buffers[2],
actual_lines,
actual_highlights,
ansi_namespace
)
if should_show_diff then
vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('diff', true, { win = current_diff_layout.windows[2] })
vim.api.nvim_win_call(current_diff_layout.windows[1], function()
vim.cmd.diffthis()
end)
vim.api.nvim_win_call(current_diff_layout.windows[2], function()
vim.cmd.diffthis()
end)
vim.api.nvim_set_option_value('foldcolumn', '0', { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('foldcolumn', '0', { win = current_diff_layout.windows[2] })
else
vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[1] })
vim.api.nvim_set_option_value('diff', false, { win = current_diff_layout.windows[2] })
end
end
end
return current_diff_layout, current_mode
end
return M

File diff suppressed because it is too large Load diff

View file

@ -1,365 +0,0 @@
local M = {}
local logger = require('cp.log')
local _nix_python = nil
local _nix_discovered = false
local uname = vim.loop.os_uname()
local _time_cached = false
local _time_path = nil
local _time_reason = nil
local _timeout_cached = false
local _timeout_path = nil
local _timeout_reason = nil
local function is_windows()
return uname.sysname == 'Windows_NT'
end
local function check_time_is_gnu_time(bin)
local ok = vim.fn.executable(bin) == 1
if not ok then
return false
end
local r = vim.system({ bin, '--version' }, { text = true }):wait()
if r and r.code == 0 and r.stdout and r.stdout:lower():find('gnu time', 1, true) then
return true
end
return false
end
local function find_gnu_time()
if _time_cached then
return _time_path, _time_reason
end
if is_windows() then
_time_cached = true
_time_path = nil
_time_reason = 'unsupported on Windows'
return _time_path, _time_reason
end
local candidates
if uname and uname.sysname == 'Darwin' then
candidates = { 'gtime', '/opt/homebrew/bin/gtime', '/usr/local/bin/gtime' }
else
candidates = { '/usr/bin/time', 'time' }
end
for _, bin in ipairs(candidates) do
if check_time_is_gnu_time(bin) then
_time_cached = true
_time_path = bin
_time_reason = nil
return _time_path, _time_reason
end
end
_time_cached = true
_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'
end
return _time_path, _time_reason
end
---@return string|nil path to GNU time binary
function M.time_path()
local path = find_gnu_time()
return path
end
---@return {ok:boolean, path:string|nil, reason:string|nil}
function M.time_capability()
local path, reason = find_gnu_time()
return { ok = path ~= nil, path = path, reason = reason }
end
---@return string
function M.get_plugin_path()
local plugin_path = debug.getinfo(1, 'S').source:sub(2)
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 }
end
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
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'
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
function M.setup_python_env()
if python_env_setup then
return true
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 .. ')')
vim.notify('[cp.nvim] Setting up Python environment...', vim.log.levels.INFO)
vim.cmd.redraw()
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 or ''),
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
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
---@param filetype? string
function M.create_buffer_with_options(filetype)
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = buf })
vim.api.nvim_set_option_value('readonly', true, { buf = buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = buf })
if filetype then
vim.api.nvim_set_option_value('filetype', filetype, { buf = buf })
end
return buf
end
---@param bufnr integer
---@param lines string[]
---@param highlights? Highlight[]
---@param namespace? integer
function M.update_buffer_content(bufnr, lines, highlights, namespace)
local was_readonly = vim.api.nvim_get_option_value('readonly', { buf = bufnr })
vim.api.nvim_set_option_value('readonly', false, { buf = bufnr })
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('readonly', was_readonly, { buf = bufnr })
if highlights and namespace then
local highlight = require('cp.ui.highlight')
highlight.apply_highlights(bufnr, highlights, namespace)
end
end
function M.check_required_runtime()
if is_windows() then
return false, 'Windows is not supported'
end
if vim.fn.has('nvim-0.10.0') ~= 1 then
return false, 'Neovim 0.10.0+ required'
end
local time = M.time_capability()
if not time.ok then
return false, time.reason
end
local timeout = M.timeout_capability()
if not timeout.ok then
return false, timeout.reason
end
return true
end
local function check_timeout_is_gnu_timeout(bin)
if vim.fn.executable(bin) ~= 1 then
return false
end
local r = vim.system({ bin, '--version' }, { text = true }):wait()
if r and r.code == 0 and r.stdout then
local s = r.stdout:lower()
if s:find('gnu coreutils', 1, true) or s:find('timeout %(gnu coreutils%)', 1, true) then
return true
end
end
return false
end
local function find_gnu_timeout()
if _timeout_cached then
return _timeout_path, _timeout_reason
end
if is_windows() then
_timeout_cached = true
_timeout_path = nil
_timeout_reason = 'unsupported on Windows'
return _timeout_path, _timeout_reason
end
local candidates
if uname and uname.sysname == 'Darwin' then
candidates = { 'gtimeout', '/opt/homebrew/bin/gtimeout', '/usr/local/bin/gtimeout' }
else
candidates = { '/usr/bin/timeout', 'timeout' }
end
for _, bin in ipairs(candidates) do
if check_timeout_is_gnu_timeout(bin) then
_timeout_cached = true
_timeout_path = bin
_timeout_reason = nil
return _timeout_path, _timeout_reason
end
end
_timeout_cached = true
_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'
end
return _timeout_path, _timeout_reason
end
function M.timeout_path()
local path = find_gnu_timeout()
return path
end
function M.timeout_capability()
local path, reason = find_gnu_timeout()
return { ok = path ~= nil, path = path, reason = reason }
end
function M.cwd_executables()
local uv = vim.uv or vim.loop
local req = uv.fs_scandir('.')
if not req then
return {}
end
local out = {}
while true do
local name, t = uv.fs_scandir_next(req)
if not name then
break
end
if t == 'file' or t == 'link' then
local path = './' .. name
if vim.fn.executable(path) == 1 then
out[#out + 1] = name
end
end
end
table.sort(out)
return out
end
function M.ensure_dirs()
vim.system({ 'mkdir', '-p', 'build', 'io' }):wait()
end
return M

View file

@ -1,9 +1,8 @@
local M = {} local M = {}
local utils = require('cp.utils')
local function get_git_version() local function get_git_version()
local plugin_root = utils.get_plugin_path() local plugin_path = debug.getinfo(1, 'S').source:sub(2)
local plugin_root = vim.fn.fnamemodify(plugin_path, ':h:h:h')
local result = vim local result = vim
.system({ 'git', 'describe', '--tags', '--always', '--dirty' }, { .system({ 'git', 'describe', '--tags', '--always', '--dirty' }, {

144
lua/cp/window.lua Normal file
View file

@ -0,0 +1,144 @@
---@class WindowState
---@field windows table<integer, WindowData>
---@field current_win integer
---@field layout string
---@class WindowData
---@field bufnr integer
---@field view table
---@field width integer
---@field height integer
local M = {}
local constants = require('cp.constants')
---@return WindowState
function M.save_layout()
local windows = {}
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(win) then
local bufnr = vim.api.nvim_win_get_buf(win)
windows[win] = {
bufnr = bufnr,
view = vim.fn.winsaveview(),
width = vim.api.nvim_win_get_width(win),
height = vim.api.nvim_win_get_height(win),
}
end
end
return {
windows = windows,
current_win = vim.api.nvim_get_current_win(),
layout = vim.fn.winrestcmd(),
}
end
---@param state? WindowState
---@param tile_fn? fun(source_buf: integer, input_buf: integer, output_buf: integer)
function M.restore_layout(state, tile_fn)
vim.validate({
state = { state, { 'table', 'nil' }, true },
tile_fn = { tile_fn, { 'function', 'nil' }, true },
})
if not state then
return
end
vim.cmd.diffoff()
local problem_id = vim.fn.expand('%:t:r')
if problem_id == '' then
for win, win_state in pairs(state.windows) do
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_buf_is_valid(win_state.bufnr) then
local bufname = vim.api.nvim_buf_get_name(win_state.bufnr)
if
not bufname:match('%.in$')
and not bufname:match('%.out$')
and not bufname:match('%.expected$')
then
problem_id = vim.fn.fnamemodify(bufname, ':t:r')
break
end
end
end
end
if problem_id ~= '' then
vim.cmd('silent only')
local base_fp = vim.fn.getcwd()
local input_file = ('%s/io/%s.in'):format(base_fp, problem_id)
local output_file = ('%s/io/%s.out'):format(base_fp, problem_id)
local source_files = vim.fn.glob(problem_id .. '.*')
local source_file
if source_files ~= '' then
local files = vim.split(source_files, '\n')
local valid_extensions = vim.tbl_keys(constants.filetype_to_language)
for _, file in ipairs(files) do
local ext = vim.fn.fnamemodify(file, ':e')
if vim.tbl_contains(valid_extensions, ext) then
source_file = file
break
end
end
source_file = source_file or files[1]
end
if not source_file or vim.fn.filereadable(source_file) == 0 then
return
end
vim.cmd.edit(source_file)
local source_buf = vim.api.nvim_get_current_buf()
local input_buf = vim.fn.bufnr(input_file, true)
local output_buf = vim.fn.bufnr(output_file, true)
if tile_fn then
tile_fn(source_buf, input_buf, output_buf)
else
M.default_tile(source_buf, input_buf, output_buf)
end
else
vim.cmd(state.layout)
for win, win_state in pairs(state.windows) do
if vim.api.nvim_win_is_valid(win) then
vim.api.nvim_set_current_win(win)
if vim.api.nvim_get_current_buf() == win_state.bufnr then
vim.fn.winrestview(win_state.view)
end
end
end
if vim.api.nvim_win_is_valid(state.current_win) then
vim.api.nvim_set_current_win(state.current_win)
end
end
end
---@param source_buf integer
---@param input_buf integer
---@param output_buf integer
local function default_tile(source_buf, input_buf, output_buf)
vim.validate({
source_buf = { source_buf, 'number' },
input_buf = { input_buf, 'number' },
output_buf = { output_buf, 'number' },
})
vim.api.nvim_set_current_buf(source_buf)
vim.cmd.vsplit()
vim.api.nvim_set_current_buf(output_buf)
vim.bo.filetype = 'cp'
vim.cmd(('vertical resize %d'):format(math.floor(vim.o.columns * 0.3)))
vim.cmd.split()
vim.api.nvim_set_current_buf(input_buf)
vim.bo.filetype = 'cp'
vim.cmd.wincmd('h')
end
M.default_tile = default_tile
return M

View file

@ -3,6 +3,10 @@ if vim.g.loaded_cp then
end end
vim.g.loaded_cp = 1 vim.g.loaded_cp = 1
local constants = require('cp.constants')
local platforms = constants.PLATFORMS
local actions = constants.ACTIONS
vim.api.nvim_create_user_command('CP', function(opts) vim.api.nvim_create_user_command('CP', function(opts)
local cp = require('cp') local cp = require('cp')
cp.handle_command(opts) cp.handle_command(opts)
@ -10,161 +14,48 @@ end, {
nargs = '*', nargs = '*',
desc = 'Competitive programming helper', desc = 'Competitive programming helper',
complete = function(ArgLead, CmdLine, _) complete = function(ArgLead, CmdLine, _)
local constants = require('cp.constants')
local platforms = constants.PLATFORMS
local actions = constants.ACTIONS
local args = vim.split(vim.trim(CmdLine), '%s+') local args = vim.split(vim.trim(CmdLine), '%s+')
local num_args = #args local num_args = #args
if CmdLine:sub(-1) == ' ' then if CmdLine:sub(-1) == ' ' then
num_args = num_args + 1 num_args = num_args + 1
end end
local function filter_candidates(candidates)
return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, candidates)
end
local function get_enabled_languages(platform)
local config = require('cp.config').get_config()
if platform and config.platforms[platform] then
return config.platforms[platform].enabled_languages
end
return vim.tbl_keys(config.languages)
end
if num_args == 2 then if num_args == 2 then
local candidates = {} local candidates = {}
local state = require('cp.state') local cp = require('cp')
local platform = state.get_platform() local context = cp.get_current_context()
local contest_id = state.get_contest_id() if context.platform and context.contest_id then
vim.list_extend(candidates, platforms)
table.insert(candidates, 'cache')
table.insert(candidates, 'pick')
if platform and contest_id then
vim.list_extend(candidates, actions) vim.list_extend(candidates, actions)
local cache = require('cp.cache') local cache = require('cp.cache')
cache.load() cache.load()
local contest_data = cache.get_contest_data(platform, contest_id) local contest_data = cache.get_contest_data(context.platform, context.contest_id)
if contest_data and contest_data.index_map then
local ids = vim.tbl_keys(contest_data.index_map)
table.sort(ids)
vim.list_extend(candidates, ids)
end
end
return filter_candidates(candidates)
elseif num_args == 3 then
if vim.tbl_contains(platforms, args[2]) then
local cache = require('cp.cache')
cache.load()
local contests = cache.get_cached_contest_ids(args[2])
return filter_candidates(contests)
elseif args[2] == 'cache' then
return filter_candidates({ 'clear', 'read' })
elseif args[2] == 'interact' then
local utils = require('cp.utils')
return filter_candidates(utils.cwd_executables())
elseif args[2] == 'edit' then
local state = require('cp.state')
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
local candidates = {}
if platform and contest_id and problem_id then
local cache = require('cp.cache')
cache.load()
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if test_cases then
for i = 1, #test_cases do
table.insert(candidates, tostring(i))
end
end
end
return filter_candidates(candidates)
elseif args[2] == 'run' or args[2] == 'panel' then
local state = require('cp.state')
local platform = state.get_platform()
local contest_id = state.get_contest_id()
local problem_id = state.get_problem_id()
local candidates = { '--debug' }
if platform and contest_id and problem_id then
local cache = require('cp.cache')
cache.load()
local test_cases = cache.get_test_cases(platform, contest_id, problem_id)
if test_cases then
for i = 1, #test_cases do
table.insert(candidates, tostring(i))
end
end
end
return filter_candidates(candidates)
elseif args[2] == 'next' or args[2] == 'prev' or args[2] == 'pick' then
return filter_candidates({ '--lang' })
else
local state = require('cp.state')
if state.get_platform() and state.get_contest_id() then
return filter_candidates({ '--lang' })
end
end
elseif num_args == 4 then
if args[2] == 'cache' and args[3] == 'clear' then
local candidates = vim.list_extend({}, platforms)
table.insert(candidates, '')
return filter_candidates(candidates)
elseif args[3] == '--lang' then
local platform = require('cp.state').get_platform()
return filter_candidates(get_enabled_languages(platform))
elseif (args[2] == 'run' or args[2] == 'panel') and tonumber(args[3]) then
return filter_candidates({ '--debug' })
elseif vim.tbl_contains(platforms, args[2]) then
local cache = require('cp.cache')
cache.load()
local contest_data = cache.get_contest_data(args[2], args[3])
local candidates = { '--lang' }
if contest_data and contest_data.problems then if contest_data and contest_data.problems then
for _, problem in ipairs(contest_data.problems) do for _, problem in ipairs(contest_data.problems) do
table.insert(candidates, problem.id) table.insert(candidates, problem.id)
end end
end end
return filter_candidates(candidates) else
vim.list_extend(candidates, platforms)
end end
elseif num_args == 5 then return vim.tbl_filter(function(cmd)
if args[2] == 'cache' and args[3] == 'clear' and vim.tbl_contains(platforms, args[4]) then return cmd:find(ArgLead, 1, true) == 1
end, candidates)
elseif num_args == 4 then
if vim.tbl_contains(platforms, args[2]) then
local cache = require('cp.cache') local cache = require('cp.cache')
cache.load() cache.load()
local contests = cache.get_cached_contest_ids(args[4]) local contest_data = cache.get_contest_data(args[2], args[3])
return filter_candidates(contests) if contest_data and contest_data.problems then
elseif vim.tbl_contains(platforms, args[2]) then local candidates = {}
if args[4] == '--lang' then for _, problem in ipairs(contest_data.problems) do
return filter_candidates(get_enabled_languages(args[2])) table.insert(candidates, problem.id)
else end
return filter_candidates({ '--lang' }) return vim.tbl_filter(function(cmd)
return cmd:find(ArgLead, 1, true) == 1
end, candidates)
end end
end end
elseif num_args == 6 then
if vim.tbl_contains(platforms, args[2]) and args[5] == '--lang' then
return filter_candidates(get_enabled_languages(args[2]))
end
end 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

@ -1,33 +1,11 @@
[project] [project]
name = "scrapers" name = "scrapers"
version = "0.1.0" version = "0.1.0"
description = "Competitive programming scrapers for a variety of web platforms." description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"backoff>=2.2.1",
"beautifulsoup4>=4.13.5", "beautifulsoup4>=4.13.5",
"curl-cffi>=0.13.0", "cloudscraper>=1.2.71",
"httpx>=0.28.1",
"ndjson>=0.3.1",
"pydantic>=2.11.10",
"requests>=2.32.5", "requests>=2.32.5",
] ]
[dependency-groups]
dev = [
"types-beautifulsoup4>=4.12.0.20250516",
"types-requests>=2.32.4.20250913",
"pytest>=8.0.0",
"pytest-mock>=3.12.0",
"pre-commit>=4.3.0",
"basedpyright>=1.31.6",
"ruff>=0.14.2",
"ty>=0.0.1a32",
]
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.mypy]
ignore_missing_imports = true

72
readme.md Normal file
View file

@ -0,0 +1,72 @@
# cp.nvim
neovim plugin for competitive programming.
https://github.com/user-attachments/assets/cb142535-fba0-4280-8f11-66ad1ca50ca9
[video config](https://github.com/barrett-ruth/dots/blob/main/nvim/lua/plugins/cp.lua)
> Sample test data from [codeforces](https://codeforces.com) is scraped via [cloudscraper](https://github.com/VeNoMouS/cloudscraper). Use at your own risk.
## Features
- Support for multiple online judges ([AtCoder](https://atcoder.jp/), [Codeforces](https://codeforces.com/), [CSES](https://cses.fi))
- Multi-language support (C++, Python)
- Automatic problem scraping and test case management
- Integrated build, run, and debug commands
- Enhanced test viewer with individual test case management
- LuaSnip integration for contest-specific snippets
## Requirements
- Neovim 0.10.0+
- [uv](https://docs.astral.sh/uv/): problem scraping (optional)
- [LuaSnip](https://github.com/L3MON4D3/LuaSnip): contest-specific snippets (optional)
## Documentation
```vim
:help cp.nvim
```
## Philosophy
This plugin is highly tuned to my workflow and may not fit for you. Personally,
I believe there are two aspects of a cp workflow:
- local work (i.e. coding, running test cases)
- site work (i.e. problem reading, submitting)
Namely, I do not like the idea of submitting problems locally - the experience
will never quite offer what the remote does. Therefore, cp.nvim works as
follows:
1. Find a problem
- Browse the remote and find it
- Read it on the remote
2. Set up your local environment with `:CP ...`
- test cases and expected output automatically scraped
- templates automatically configured
3. Solve the problem locally
- easy to run/debug
- easy to diff actual vs. expected output
4. Submit the problem (on the remote!)
## Similar Projects
- [competitest.nvim](https://github.com/xeluxee/competitest.nvim)
- [assistant.nvim](https://github.com/A7Lavinraj/assistant.nvim)
## TODO
- fzf/telescope integration (whichever available)
- autocomplete with --lang and --debug
- finer-tuned problem limits (i.e. per-problem codeforces time, memory)
- notify discord members

View file

View file

@ -1,398 +1,222 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio
import json import json
import re
import sys import sys
import time
from typing import Any
import backoff
import httpx
import requests import requests
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .base import BaseScraper
from .models import (
CombinedTest,
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
TestsResult,
)
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"
}
RETRY_STATUS = {429, 502, 503, 504}
FATAL_STATUS = {400, 401, 403, 404, 410}
_session = requests.Session()
_adapter = HTTPAdapter(
pool_connections=100,
pool_maxsize=100,
max_retries=Retry(total=0),
)
_session.mount("https://", _adapter)
_session.mount("http://", _adapter)
def _give_up_requests(exc: Exception) -> bool: def parse_problem_url(contest_id: str, problem_letter: str) -> str:
if isinstance(exc, requests.HTTPError) and exc.response is not None: task_id: str = f"{contest_id}_{problem_letter}"
return exc.response.status_code in FATAL_STATUS return f"https://atcoder.jp/contests/{contest_id}/tasks/{task_id}"
return False
def _retry_after_requests(details): def extract_problem_from_row(row, contest_id: str) -> dict[str, str] | None:
exc = details.get("exception") cells = row.find_all("td")
if isinstance(exc, requests.HTTPError) and exc.response is not None: if len(cells) < 2:
ra = exc.response.headers.get("Retry-After") return None
if ra:
try: task_link = cells[1].find("a")
time.sleep(max(0.0, float(ra))) if not task_link:
except ValueError: return None
pass
task_name = task_link.get_text(strip=True)
task_href = task_link.get("href", "")
if not task_href:
return None
task_id = task_href.split("/")[-1]
if not task_id.startswith(contest_id + "_"):
return None
problem_letter = task_id[len(contest_id) + 1 :]
if not problem_letter or not task_name:
return None
return {"id": problem_letter.lower(), "name": task_name}
@backoff.on_exception( def scrape_contest_problems(contest_id: str) -> list[dict[str, str]]:
backoff.expo,
(requests.ConnectionError, requests.Timeout, requests.HTTPError),
max_tries=5,
jitter=backoff.full_jitter,
giveup=_give_up_requests,
on_backoff=_retry_after_requests,
)
def _fetch(url: str) -> str:
r = _session.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS)
if r.status_code in RETRY_STATUS:
raise requests.HTTPError(response=r)
r.raise_for_status()
return r.text
def _giveup_httpx(exc: Exception) -> bool:
return (
isinstance(exc, httpx.HTTPStatusError)
and exc.response is not None
and (exc.response.status_code in FATAL_STATUS)
)
@backoff.on_exception(
backoff.expo,
(httpx.ConnectError, httpx.ReadTimeout, httpx.HTTPStatusError),
max_tries=5,
jitter=backoff.full_jitter,
giveup=_giveup_httpx,
)
async def _get_async(client: httpx.AsyncClient, url: str) -> str:
r = await client.get(url, headers=HEADERS, timeout=TIMEOUT_SECONDS)
r.raise_for_status()
return r.text
def _text_from_pre(pre: Tag) -> str:
return (
pre.get_text(separator="\n", strip=False)
.replace("\r", "")
.replace("\xa0", " ")
.rstrip("\n")
)
def _parse_last_page(html: str) -> int:
soup = BeautifulSoup(html, "html.parser")
nav = soup.select_one("ul.pagination")
if not nav:
return 1
nums = []
for a in nav.select("a"):
s = a.get_text(strip=True)
if s.isdigit():
nums.append(int(s))
return max(nums) if nums else 1
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")
if not tbody:
return []
out: list[ContestSummary] = []
for tr in tbody.select("tr"):
a = tr.select_one("a[href^='/contests/']")
if not a:
continue
href_attr = a.get("href")
if not isinstance(href_attr, str):
continue
m = re.search(r"/contests/([^/?#]+)", href_attr)
if not m:
continue
cid = m.group(1)
name = a.get_text(strip=True)
out.append(ContestSummary(id=cid, name=name, display_name=name))
return out
def _parse_tasks_list(html: str) -> list[dict[str, str]]:
soup = BeautifulSoup(html, "html.parser")
tbody = soup.select_one("table tbody")
if not tbody:
return []
rows: list[dict[str, str]] = []
for tr in tbody.select("tr"):
tds = tr.select("td")
if len(tds) < 2:
continue
letter = tds[0].get_text(strip=True)
a = tds[1].select_one("a[href*='/tasks/']")
if not a:
continue
href_attr = a.get("href")
if not isinstance(href_attr, str):
continue
m = re.search(r"/contests/[^/]+/tasks/([^/?#]+)", href_attr)
if not m:
continue
slug = m.group(1)
title = a.get_text(strip=True)
rows.append({"letter": letter, "title": title, "slug": slug})
return rows
def _extract_problem_info(html: str) -> tuple[int, float, bool]:
soup = BeautifulSoup(html, "html.parser")
txt = soup.get_text(" ", strip=True)
timeout_ms = 0
memory_mb = 0.0
ts = re.search(r"Time\s*Limit:\s*([\d.]+)\s*sec", txt, flags=re.I)
if ts:
timeout_ms = int(float(ts.group(1)) * 1000)
ms = re.search(r"Memory\s*Limit:\s*(\d+)\s*MiB", txt, flags=re.I)
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
def _extract_samples(html: str) -> list[TestCase]:
soup = BeautifulSoup(html, "html.parser")
root = soup.select_one("#task-statement") or soup
inputs: dict[str, str] = {}
outputs: dict[str, str] = {}
for h in root.find_all(re.compile(r"h[2-4]")):
title = h.get_text(" ", strip=True)
pre = h.find_next("pre")
if not pre:
continue
t = _text_from_pre(pre)
mi = re.search(r"Sample\s*Input\s*(\d+)", title, flags=re.I)
mo = re.search(r"Sample\s*Output\s*(\d+)", title, flags=re.I)
if mi:
inputs[mi.group(1)] = t.strip()
elif mo:
outputs[mo.group(1)] = t.strip()
cases: list[TestCase] = []
for k in sorted(set(inputs) & set(outputs), key=lambda s: int(s)):
cases.append(TestCase(input=inputs[k], expected=outputs[k]))
return cases
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)
def _scrape_problem_page_sync(contest_id: str, slug: str) -> dict[str, Any]:
html = _fetch(f"{BASE_URL}/contests/{contest_id}/tasks/{slug}")
try: try:
tests = _extract_samples(html) contest_url = f"https://atcoder.jp/contests/{contest_id}/tasks"
except Exception: headers = {
tests = [] "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
timeout_ms, memory_mb, interactive = _extract_problem_info(html) }
return {
"tests": tests, response = requests.get(contest_url, headers=headers, timeout=10)
"timeout_ms": timeout_ms, response.raise_for_status()
"memory_mb": memory_mb,
"interactive": interactive, soup = BeautifulSoup(response.text, "html.parser")
} task_table = soup.find("table", class_="table")
if not task_table:
return []
rows = task_table.find_all("tr")[1:]
problems = []
for row in rows:
problem = extract_problem_from_row(row, contest_id)
if problem:
problems.append(problem)
problems.sort(key=lambda x: x["id"])
return problems
except Exception as e:
print(f"Failed to scrape AtCoder contest problems: {e}", file=sys.stderr)
return []
def _to_problem_summaries(rows: list[dict[str, str]]) -> list[ProblemSummary]: def extract_test_case_from_headers(sample_headers, i: int) -> tuple[str, str] | None:
out: list[ProblemSummary] = [] if i >= len(sample_headers):
for r in rows: return None
letter = (r.get("letter") or "").strip().upper()
title = r.get("title") or "" header = sample_headers[i]
if not letter: if "input" not in header.get_text().lower():
continue return None
pid = letter.lower()
out.append(ProblemSummary(id=pid, name=title)) input_pre = header.find_next("pre")
return out if not input_pre or i + 1 >= len(sample_headers):
return None
next_header = sample_headers[i + 1]
if "output" not in next_header.get_text().lower():
return None
output_pre = next_header.find_next("pre")
if not output_pre:
return None
input_text = input_pre.get_text().strip().replace("\r", "")
output_text = output_pre.get_text().strip().replace("\r", "")
if not input_text or not output_text:
return None
return (input_text, output_text)
async def _fetch_all_contests_async() -> list[ContestSummary]: def scrape(url: str) -> list[tuple[str, str]]:
async with httpx.AsyncClient( try:
limits=httpx.Limits(max_connections=100, max_keepalive_connections=100), headers = {
) as client: "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
first_html = await _get_async(client, ARCHIVE_URL) }
last = _parse_last_page(first_html)
out = _parse_archive_contests(first_html)
if last <= 1:
return out
tasks = [
asyncio.create_task(_get_async(client, f"{ARCHIVE_URL}?page={p}"))
for p in range(2, last + 1)
]
for coro in asyncio.as_completed(tasks):
html = await coro
out.extend(_parse_archive_contests(html))
return out
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
class AtcoderScraper(BaseScraper): soup = BeautifulSoup(response.text, "html.parser")
@property
def platform_name(self) -> str:
return "atcoder"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: sample_headers = soup.find_all(
try: "h3", string=lambda x: x and "sample" in x.lower() if x else False
rows = await asyncio.to_thread(_scrape_tasks_sync, contest_id)
problems = _to_problem_summaries(rows)
if not problems:
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"https://atcoder.jp/contests/{contest_id}/tasks/{contest_id}_%s",
)
except Exception as e:
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
try:
contests = await _fetch_all_contests_async()
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:
rows = await asyncio.to_thread(_scrape_tasks_sync, category_id)
async def emit(row: dict[str, str]) -> None:
letter = (row.get("letter") or "").strip().lower()
slug = row.get("slug") or ""
if not letter or not slug:
return
data = await asyncio.to_thread(_scrape_problem_page_sync, category_id, slug)
tests: list[TestCase] = data.get("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 ""
print(
json.dumps(
{
"problem_id": letter,
"combined": {
"input": combined_input,
"expected": combined_expected,
},
"tests": [
{"input": t.input, "expected": t.expected} for t in tests
],
"timeout_ms": data.get("timeout_ms", 0),
"memory_mb": data.get("memory_mb", 0),
"interactive": bool(data.get("interactive")),
"multi_test": False,
}
),
flush=True,
)
await asyncio.gather(*(emit(r) for r in rows))
async def main_async() -> int:
if len(sys.argv) < 2:
result = MetadataResult(
success=False,
error="Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> OR atcoder.py contests",
url="",
) )
print(result.model_dump_json())
return 1
mode: str = sys.argv[1] tests = []
scraper = AtcoderScraper() i = 0
if mode == "metadata": while i < len(sample_headers):
if len(sys.argv) != 3: test_case = extract_test_case_from_headers(sample_headers, i)
result = MetadataResult( if test_case:
success=False, tests.append(test_case)
error="Usage: atcoder.py metadata <contest_id>", i += 2
url="", else:
) i += 1
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": return tests
if len(sys.argv) != 3:
tests_result = TestsResult(
success=False,
error="Usage: atcoder.py tests <contest_id>",
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": except Exception as e:
if len(sys.argv) != 2: print(f"Error scraping AtCoder: {e}", file=sys.stderr)
contest_result = ContestListResult( return []
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
result = MetadataResult(
success=False,
error="Unknown mode. Use 'metadata <contest_id>', 'tests <contest_id>', or 'contests'",
url="",
)
print(result.model_dump_json())
return 1
def main() -> None: def main() -> None:
sys.exit(asyncio.run(main_async())) if len(sys.argv) < 2:
result: dict[str, str | bool] = {
"success": False,
"error": "Usage: atcoder.py metadata <contest_id> OR atcoder.py tests <contest_id> <problem_letter>",
}
print(json.dumps(result))
sys.exit(1)
mode: str = sys.argv[1]
if mode == "metadata":
if len(sys.argv) != 3:
result = {
"success": False,
"error": "Usage: atcoder.py metadata <contest_id>",
}
print(json.dumps(result))
sys.exit(1)
contest_id: str = sys.argv[2]
problems: list[dict[str, str]] = scrape_contest_problems(contest_id)
if not problems:
result = {
"success": False,
"error": f"No problems found for contest {contest_id}",
}
print(json.dumps(result))
sys.exit(1)
result = {
"success": True,
"contest_id": contest_id,
"problems": problems,
}
print(json.dumps(result))
elif mode == "tests":
if len(sys.argv) != 4:
result = {
"success": False,
"error": "Usage: atcoder.py tests <contest_id> <problem_letter>",
}
print(json.dumps(result))
sys.exit(1)
contest_id: str = sys.argv[2]
problem_letter: str = sys.argv[3]
problem_id: str = contest_id + problem_letter.lower()
url: str = parse_problem_url(contest_id, problem_letter)
print(f"Scraping: {url}", file=sys.stderr)
tests: list[tuple[str, str]] = scrape(url)
if not tests:
result = {
"success": False,
"error": f"No tests found for {contest_id} {problem_letter}",
"problem_id": problem_id,
"url": url,
}
print(json.dumps(result))
sys.exit(1)
test_list: list[dict[str, str]] = []
for input_data, output_data in tests:
test_list.append({"input": input_data, "expected": output_data})
result = {
"success": True,
"problem_id": problem_id,
"url": url,
"tests": test_list,
}
print(json.dumps(result))
else:
result = {
"success": False,
"error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'",
}
print(json.dumps(result))
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,83 +0,0 @@
import asyncio
import sys
from abc import ABC, abstractmethod
from .models import CombinedTest, ContestListResult, MetadataResult, TestsResult
class BaseScraper(ABC):
@property
@abstractmethod
def platform_name(self) -> str: ...
@abstractmethod
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: ...
@abstractmethod
async def scrape_contest_list(self) -> ContestListResult: ...
@abstractmethod
async def stream_tests_for_category_async(self, category_id: str) -> None: ...
def _usage(self) -> str:
name = self.platform_name
return f"Usage: {name}.py metadata <id> | tests <id> | contests"
def _metadata_error(self, msg: str) -> MetadataResult:
return MetadataResult(success=False, error=msg, url="")
def _tests_error(self, msg: str) -> TestsResult:
return TestsResult(
success=False,
error=msg,
problem_id="",
combined=CombinedTest(input="", expected=""),
tests=[],
timeout_ms=0,
memory_mb=0,
)
def _contests_error(self, msg: str) -> ContestListResult:
return ContestListResult(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())
return 1
mode = args[1]
match mode:
case "metadata":
if len(args) != 3:
print(self._metadata_error(self._usage()).model_dump_json())
return 1
result = await self.scrape_contest_metadata(args[2])
print(result.model_dump_json())
return 0 if result.success else 1
case "tests":
if len(args) != 3:
print(self._tests_error(self._usage()).model_dump_json())
return 1
await self.stream_tests_for_category_async(args[2])
return 0
case "contests":
if len(args) != 2:
print(self._contests_error(self._usage()).model_dump_json())
return 1
result = await self.scrape_contest_list()
print(result.model_dump_json())
return 0 if result.success else 1
case _:
print(
self._metadata_error(
f"Unknown mode: {mode}. {self._usage()}"
).model_dump_json()
)
return 1
def run_cli(self) -> None:
sys.exit(asyncio.run(self._run_cli_async(sys.argv)))

View file

@ -1,253 +0,0 @@
#!/usr/bin/env python3
import asyncio
import json
import re
from typing import Any
import httpx
from curl_cffi import requests as curl_requests
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
)
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"
}
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
)
async def fetch_json(client: httpx.AsyncClient, path: str) -> dict:
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S)
r.raise_for_status()
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=TIMEOUT_S)
response.raise_for_status()
return response.text
class CodeChefScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "codechef"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
try:
async with httpx.AsyncClient() as client:
data = await fetch_json(
client, API_CONTEST.format(contest_id=contest_id)
)
if not data.get("problems"):
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
problems = []
for problem_code, problem_data in data["problems"].items():
if problem_data.get("category_name") == "main":
problems.append(
ProblemSummary(
id=problem_code,
name=problem_data.get("name", problem_code),
)
)
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"{BASE_URL}/{contest_id}",
)
except Exception as e:
return self._metadata_error(f"Failed to fetch contest {contest_id}: {e}")
async def scrape_contest_list(self) -> ContestListResult:
async with httpx.AsyncClient() as client:
try:
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", []
)
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)
return ContestListResult(success=True, error="", contests=contests)
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:
contest_data = await fetch_json(
client, API_CONTEST.format(contest_id=category_id)
)
except Exception as e:
print(
json.dumps(
{"error": f"Failed to fetch contest {category_id}: {str(e)}"}
),
flush=True,
)
return
all_problems = contest_data.get("problems", {})
if not all_problems:
print(
json.dumps(
{"error": f"No problems found for contest {category_id}"}
),
flush=True,
)
return
problems = {
code: data
for code, data in all_problems.items()
if data.get("category_name") == "main"
}
if not problems:
print(
json.dumps(
{"error": f"No main problems found for contest {category_id}"}
),
flush=True,
)
return
sem = asyncio.Semaphore(CONNECTIONS)
async def run_one(problem_code: str) -> dict[str, Any]:
async with sem:
try:
problem_data = await fetch_json(
client,
API_PROBLEM.format(
contest_id=category_id, problem_id=problem_code
),
)
sample_tests = (
problem_data.get("problemComponents", {}).get(
"sampleTestCases", []
)
or []
)
tests = [
TestCase(
input=t.get("input", "").strip(),
expected=t.get("output", "").strip(),
)
for t in sample_tests
if not t.get("isDeleted", False)
]
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)
interactive = False
except Exception:
tests = []
timeout_ms = 1000
memory_mb = 256.0
interactive = False
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": problem_code,
"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,
}
tasks = [run_one(problem_code) for problem_code in problems.keys()]
for coro in asyncio.as_completed(tasks):
payload = await coro
print(json.dumps(payload), flush=True)
if __name__ == "__main__":
CodeChefScraper().run_cli()

View file

@ -1,273 +1,270 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio
import json import json
import re import sys
from typing import Any
import requests import cloudscraper
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup
from curl_cffi import requests as curl_requests
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
)
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"
}
def _text_from_pre(pre: Tag) -> str: def scrape(url: str) -> list[tuple[str, str]]:
return ( try:
pre.get_text(separator="\n", strip=False) scraper = cloudscraper.create_scraper()
.replace("\r", "") response = scraper.get(url, timeout=10)
.replace("\xa0", " ") response.raise_for_status()
.strip()
)
soup = BeautifulSoup(response.text, "html.parser")
input_sections = soup.find_all("div", class_="input")
output_sections = soup.find_all("div", class_="output")
def _extract_limits(block: Tag) -> tuple[int, float]: individual_inputs = {}
tdiv = block.find("div", class_="time-limit") individual_outputs = {}
mdiv = block.find("div", class_="memory-limit")
timeout_ms = 0
memory_mb = 0.0
if tdiv:
ttxt = tdiv.get_text(" ", strip=True)
ts = re.search(r"(\d+)\s*seconds?", ttxt)
if ts:
timeout_ms = int(ts.group(1)) * 1000
if mdiv:
mtxt = mdiv.get_text(" ", strip=True)
ms = re.search(r"(\d+)\s*megabytes?", mtxt)
if ms:
memory_mb = float(ms.group(1))
return timeout_ms, memory_mb
for inp_section in input_sections:
inp_pre = inp_section.find("pre")
if not inp_pre:
continue
def _group_lines_by_id(pre: Tag) -> dict[int, list[str]]: test_line_divs = inp_pre.find_all(
groups: dict[int, list[str]] = {} "div", class_=lambda x: x and "test-example-line-" in x
for div in pre.find_all("div", class_="test-example-line"): )
cls = " ".join(div.get("class", [])) if not test_line_divs:
m = re.search(r"\btest-example-line-(\d+)\b", cls) continue
if not m:
continue
gid = int(m.group(1))
groups.setdefault(gid, []).append(div.get_text("", strip=False))
return groups
for div in test_line_divs:
def _extract_title(block: Tag) -> tuple[str, str]: classes = div.get("class", [])
t = block.find("div", class_="title") class_name = next(
if not t: (
return "", "" cls
s = t.get_text(" ", strip=True) for cls in classes
parts = s.split(".", 1) if "test-example-line-" in cls and cls.split("-")[-1].isdigit()
if len(parts) != 2: ),
return "", s.strip() None,
return parts[0].strip().upper(), parts[1].strip()
def _extract_samples(block: Tag) -> tuple[list[TestCase], bool]:
st = block.find("div", class_="sample-test")
if not isinstance(st, Tag):
return [], False
input_pres: list[Tag] = [
inp.find("pre")
for inp in st.find_all("div", class_="input")
if isinstance(inp, Tag) and inp.find("pre")
]
output_pres: list[Tag] = [
out.find("pre")
for out in st.find_all("div", class_="output")
if isinstance(out, Tag) and out.find("pre")
]
input_pres = [p for p in input_pres if isinstance(p, Tag)]
output_pres = [p for p in output_pres if isinstance(p, Tag)]
has_grouped = any(
p.find("div", class_="test-example-line") for p in input_pres + output_pres
)
if has_grouped:
inputs_by_gid: dict[int, list[str]] = {}
outputs_by_gid: dict[int, list[str]] = {}
for p in input_pres:
g = _group_lines_by_id(p)
for k, v in g.items():
inputs_by_gid.setdefault(k, []).extend(v)
for p in output_pres:
g = _group_lines_by_id(p)
for k, v in g.items():
outputs_by_gid.setdefault(k, []).extend(v)
inputs_by_gid.pop(0, None)
outputs_by_gid.pop(0, None)
keys = sorted(set(inputs_by_gid.keys()) & set(outputs_by_gid.keys()))
if keys:
samples = [
TestCase(
input="\n".join(inputs_by_gid[k]).strip(),
expected="\n".join(outputs_by_gid[k]).strip(),
) )
for k in keys if not class_name:
]
return samples, True
inputs = [_text_from_pre(p) for p in input_pres]
outputs = [_text_from_pre(p) for p in output_pres]
n = min(len(inputs), len(outputs))
return [TestCase(input=inputs[i], expected=outputs[i]) for i in range(n)], False
def _is_interactive(block: Tag) -> bool:
ps = block.find("div", class_="problem-statement")
txt = ps.get_text(" ", strip=True) if ps else block.get_text(" ", strip=True)
return "This is an interactive problem" in txt
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.raise_for_status()
return response.text
def _parse_all_blocks(html: str) -> list[dict[str, Any]]:
soup = BeautifulSoup(html, "html.parser")
blocks = soup.find_all("div", class_="problem-statement")
out: list[dict[str, Any]] = []
for b in blocks:
holder = b.find_parent("div", class_="problemindexholder")
letter = (holder.get("problemindex") if holder else "").strip().upper()
name = _extract_title(b)[1]
if not letter:
continue
raw_samples, is_grouped = _extract_samples(b)
timeout_ms, memory_mb = _extract_limits(b)
interactive = _is_interactive(b)
if is_grouped and raw_samples:
combined_input = f"{len(raw_samples)}\n" + "\n".join(
tc.input for tc in raw_samples
)
combined_expected = "\n".join(tc.expected for tc in raw_samples)
individual_tests = [
TestCase(input=f"1\n{tc.input}", expected=tc.expected)
for tc in raw_samples
]
else:
combined_input = "\n".join(tc.input for tc in raw_samples)
combined_expected = "\n".join(tc.expected for tc in raw_samples)
individual_tests = raw_samples
out.append(
{
"letter": letter,
"name": name,
"combined_input": combined_input,
"combined_expected": combined_expected,
"tests": individual_tests,
"timeout_ms": timeout_ms,
"memory_mb": memory_mb,
"interactive": interactive,
"multi_test": is_grouped,
}
)
return out
def _scrape_contest_problems_sync(contest_id: str) -> list[ProblemSummary]:
html = _fetch_problems_html(contest_id)
blocks = _parse_all_blocks(html)
problems: list[ProblemSummary] = []
for b in blocks:
pid = b["letter"].upper()
problems.append(ProblemSummary(id=pid.lower(), name=b["name"]))
return problems
class CodeforcesScraper(BaseScraper):
@property
def platform_name(self) -> str:
return "codeforces"
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult:
try:
problems = await asyncio.to_thread(
_scrape_contest_problems_sync, contest_id
)
if not problems:
return self._metadata_error(
f"No problems found for contest {contest_id}"
)
return MetadataResult(
success=True,
error="",
contest_id=contest_id,
problems=problems,
url=f"https://codeforces.com/contest/{contest_id}/problem/%s",
)
except Exception as e:
return self._metadata_error(str(e))
async def scrape_contest_list(self) -> ContestListResult:
try:
r = requests.get(API_CONTEST_LIST_URL, timeout=TIMEOUT_SECONDS)
r.raise_for_status()
data = r.json()
if data.get("status") != "OK":
return self._contests_error("Invalid API response")
contests: list[ContestSummary] = []
for c in data["result"]:
if c.get("phase") != "FINISHED":
continue continue
cid = str(c["id"])
name = c["name"]
contests.append(ContestSummary(id=cid, name=name, display_name=name))
if not contests: test_num = class_name.replace("test-example-line-", "")
return self._contests_error("No contests found") if test_num not in individual_inputs:
individual_inputs[test_num] = []
individual_inputs[test_num].append(div.get_text().strip())
return ContestListResult(success=True, error="", contests=contests) for out_section in output_sections:
except Exception as e: out_pre = out_section.find("pre")
return self._contests_error(str(e)) if not out_pre:
continue
async def stream_tests_for_category_async(self, category_id: str) -> None: test_line_divs = out_pre.find_all(
html = await asyncio.to_thread(_fetch_problems_html, category_id) "div", class_=lambda x: x and "test-example-line-" in x
blocks = await asyncio.to_thread(_parse_all_blocks, html)
for b in blocks:
pid = b["letter"].lower()
tests: list[TestCase] = b.get("tests", [])
print(
json.dumps(
{
"problem_id": pid,
"combined": {
"input": b.get("combined_input", ""),
"expected": b.get("combined_expected", ""),
},
"tests": [
{"input": t.input, "expected": t.expected} for t in tests
],
"timeout_ms": b.get("timeout_ms", 0),
"memory_mb": b.get("memory_mb", 0),
"interactive": bool(b.get("interactive")),
"multi_test": bool(b.get("multi_test", False)),
}
),
flush=True,
) )
if not test_line_divs:
continue
for div in test_line_divs:
classes = div.get("class", [])
class_name = next(
(
cls
for cls in classes
if "test-example-line-" in cls and cls.split("-")[-1].isdigit()
),
None,
)
if not class_name:
continue
test_num = class_name.replace("test-example-line-", "")
if test_num not in individual_outputs:
individual_outputs[test_num] = []
individual_outputs[test_num].append(div.get_text().strip())
if individual_inputs and individual_outputs:
common_tests = set(individual_inputs.keys()) & set(
individual_outputs.keys()
)
if common_tests:
tests = []
for test_num in sorted(common_tests):
input_text = "\n".join(individual_inputs[test_num])
output_text = "\n".join(individual_outputs[test_num])
prefixed_input = "1\n" + input_text
tests.append((prefixed_input, output_text))
return tests
all_inputs = []
all_outputs = []
for inp_section in input_sections:
inp_pre = inp_section.find("pre")
if not inp_pre:
continue
divs = inp_pre.find_all("div")
if divs:
lines = [div.get_text().strip() for div in divs]
text = "\n".join(lines)
else:
text = inp_pre.get_text().replace("\r", "").strip()
all_inputs.append(text)
for out_section in output_sections:
out_pre = out_section.find("pre")
if not out_pre:
continue
divs = out_pre.find_all("div")
if divs:
lines = [div.get_text().strip() for div in divs]
text = "\n".join(lines)
else:
text = out_pre.get_text().replace("\r", "").strip()
all_outputs.append(text)
if not all_inputs or not all_outputs:
return []
combined_input = "\n".join(all_inputs)
combined_output = "\n".join(all_outputs)
return [(combined_input, combined_output)]
except Exception as e:
print(f"CloudScraper failed: {e}", file=sys.stderr)
return []
def parse_problem_url(contest_id: str, problem_letter: str) -> str:
return (
f"https://codeforces.com/contest/{contest_id}/problem/{problem_letter.upper()}"
)
def scrape_contest_problems(contest_id: str) -> list[dict[str, str]]:
try:
contest_url: str = f"https://codeforces.com/contest/{contest_id}"
scraper = cloudscraper.create_scraper()
response = scraper.get(contest_url, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
problems: list[dict[str, str]] = []
problem_links = soup.find_all(
"a", href=lambda x: x and f"/contest/{contest_id}/problem/" in x
)
for link in problem_links:
href: str = link.get("href", "")
if f"/contest/{contest_id}/problem/" in href:
problem_letter: str = href.split("/")[-1].lower()
problem_name: str = link.get_text(strip=True)
if problem_letter and problem_name:
problems.append({"id": problem_letter, "name": problem_name})
problems.sort(key=lambda x: x["id"])
seen: set[str] = set()
unique_problems: list[dict[str, str]] = []
for p in problems:
if p["id"] not in seen:
seen.add(p["id"])
unique_problems.append(p)
return unique_problems
except Exception as e:
print(f"Failed to scrape contest problems: {e}", file=sys.stderr)
return []
def scrape_sample_tests(url: str) -> list[tuple[str, str]]:
print(f"Scraping: {url}", file=sys.stderr)
return scrape(url)
def main() -> None:
if len(sys.argv) < 2:
result: dict[str, str | bool] = {
"success": False,
"error": "Usage: codeforces.py metadata <contest_id> OR codeforces.py tests <contest_id> <problem_letter>",
}
print(json.dumps(result))
sys.exit(1)
mode: str = sys.argv[1]
if mode == "metadata":
if len(sys.argv) != 3:
result: dict[str, str | bool] = {
"success": False,
"error": "Usage: codeforces.py metadata <contest_id>",
}
print(json.dumps(result))
sys.exit(1)
contest_id: str = sys.argv[2]
problems: list[dict[str, str]] = scrape_contest_problems(contest_id)
if not problems:
result: dict[str, str | bool] = {
"success": False,
"error": f"No problems found for contest {contest_id}",
}
print(json.dumps(result))
sys.exit(1)
result: dict[str, str | bool | list] = {
"success": True,
"contest_id": contest_id,
"problems": problems,
}
print(json.dumps(result))
elif mode == "tests":
if len(sys.argv) != 4:
result: dict[str, str | bool] = {
"success": False,
"error": "Usage: codeforces.py tests <contest_id> <problem_letter>",
}
print(json.dumps(result))
sys.exit(1)
contest_id: str = sys.argv[2]
problem_letter: str = sys.argv[3]
problem_id: str = contest_id + problem_letter.lower()
url: str = parse_problem_url(contest_id, problem_letter)
tests: list[tuple[str, str]] = scrape_sample_tests(url)
if not tests:
result: dict[str, str | bool] = {
"success": False,
"error": f"No tests found for {contest_id} {problem_letter}",
"problem_id": problem_id,
"url": url,
}
print(json.dumps(result))
sys.exit(1)
test_list: list[dict[str, str]] = []
for input_data, output_data in tests:
test_list.append({"input": input_data, "expected": output_data})
result: dict[str, str | bool | list] = {
"success": True,
"problem_id": problem_id,
"url": url,
"tests": test_list,
}
print(json.dumps(result))
else:
result: dict[str, str | bool] = {
"success": False,
"error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'",
}
print(json.dumps(result))
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
CodeforcesScraper().run_cli() main()

407
scrapers/cses.py Normal file → Executable file
View file

@ -1,262 +1,223 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio
import json import json
import re import sys
from typing import Any
import httpx import requests
from bs4 import BeautifulSoup
from .base import BaseScraper
from .models import (
ContestListResult,
ContestSummary,
MetadataResult,
ProblemSummary,
TestCase,
)
BASE_URL = "https://cses.fi"
INDEX_PATH = "/problemset"
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
def normalize_category_name(category_name: str) -> str: def parse_problem_url(problem_input: str) -> str | None:
return category_name.lower().replace(" ", "_").replace("&", "and") if problem_input.startswith("https://cses.fi/problemset/task/"):
return problem_input
elif problem_input.isdigit():
return f"https://cses.fi/problemset/task/{problem_input}"
return None
def snake_to_title(name: str) -> str: def process_problem_element(
small_words = { element, current_category: str, all_categories: dict
"a", ) -> str | None:
"an", if element.name == "h1":
"the", category_name = element.get_text().strip()
"and", if category_name not in all_categories:
"but", all_categories[category_name] = []
"or", return category_name
"nor",
"for",
"so",
"yet",
"at",
"by",
"in",
"of",
"on",
"per",
"to",
"vs",
"via",
}
words: list[str] = name.split("_")
n = len(words)
def fix_word(i_word): if element.name != "a" or "/problemset/task/" not in element.get("href", ""):
i, word = i_word return current_category
lw = word.lower()
return lw.capitalize() if i == 0 or i == n - 1 or lw not in small_words else lw
return " ".join(map(fix_word, enumerate(words))) href = element.get("href", "")
if not href:
return current_category
problem_id = href.split("/")[-1]
problem_name = element.get_text(strip=True)
if not (problem_id.isdigit() and problem_name and current_category):
return current_category
all_categories[current_category].append({"id": problem_id, "name": problem_name})
return current_category
async def fetch_text(client: httpx.AsyncClient, path: str) -> str: def scrape_all_problems() -> dict[str, list[dict[str, str]]]:
r = await client.get(BASE_URL + path, headers=HEADERS, timeout=TIMEOUT_S) try:
r.raise_for_status() problemset_url = "https://cses.fi/problemset/"
return r.text headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
response = requests.get(problemset_url, headers=headers, timeout=10)
response.raise_for_status()
CATEGORY_BLOCK_RE = re.compile( soup = BeautifulSoup(response.text, "html.parser")
r'<h2>(?P<cat>[^<]+)</h2>\s*<ul\s+class="task-list">(?P<body>.*?)</ul>', all_categories = {}
re.DOTALL,
)
TASK_LINK_RE = re.compile(
r'<li\s+class="task">\s*<a\s+href="/problemset/task/(?P<id>\d+)/?">(?P<title>[^<]+)</a\s*>',
re.DOTALL,
)
TITLE_RE = re.compile( problem_links = soup.find_all(
r'<div\s+class="title-block">.*?<h1>(?P<title>[^<]+)</h1>', re.DOTALL "a", href=lambda x: x and "/problemset/task/" in x
)
TIME_RE = re.compile(r"<li>\s*<b>Time limit:</b>\s*([0-9.]+)\s*s\s*</li>")
MEM_RE = re.compile(r"<li>\s*<b>Memory limit:</b>\s*(\d+)\s*MB\s*</li>")
SIDEBAR_CAT_RE = re.compile(
r'<div\s+class="nav sidebar">.*?<h4>(?P<cat>[^<]+)</h4>', re.DOTALL
)
MD_BLOCK_RE = re.compile(r'<div\s+class="md">(.*?)</div>', re.DOTALL | re.IGNORECASE)
EXAMPLE_SECTION_RE = re.compile(
r"<h[1-6][^>]*>\s*example[s]?:?\s*</h[1-6]>\s*(?P<section>.*?)(?=<h[1-6][^>]*>|$)",
re.DOTALL | re.IGNORECASE,
)
LABELED_IO_RE = re.compile(
r"input\s*:\s*</p>\s*<pre>(?P<input>.*?)</pre>.*?output\s*:\s*</p>\s*<pre>(?P<output>.*?)</pre>",
re.DOTALL | re.IGNORECASE,
)
PRE_RE = re.compile(r"<pre>(.*?)</pre>", re.DOTALL | re.IGNORECASE)
def parse_categories(html: str) -> list[ContestSummary]:
out: list[ContestSummary] = []
for m in CATEGORY_BLOCK_RE.finditer(html):
cat = m.group("cat").strip()
if cat == "General":
continue
out.append(
ContestSummary(
id=normalize_category_name(cat),
name=cat,
display_name=cat,
)
) )
return out print(f"Found {len(problem_links)} problem links", file=sys.stderr)
current_category = None
for element in soup.find_all(["h1", "a"]):
current_category = process_problem_element(
element, current_category, all_categories
)
for category in all_categories:
all_categories[category].sort(key=lambda x: int(x["id"]))
print(f"Found {len(all_categories)} categories", file=sys.stderr)
return all_categories
except Exception as e:
print(f"Failed to scrape CSES problems: {e}", file=sys.stderr)
return {}
def parse_category_problems(category_id: str, html: str) -> list[ProblemSummary]: def extract_example_test_case(soup) -> tuple[str, str] | None:
want = snake_to_title(category_id) example_header = soup.find("h1", string="Example")
for m in CATEGORY_BLOCK_RE.finditer(html): if not example_header:
cat = m.group("cat").strip() return None
if cat != want:
continue current = example_header.find_next_sibling()
body = m.group("body") input_text = None
return [ output_text = None
ProblemSummary(id=mm.group("id"), name=mm.group("title"))
for mm in TASK_LINK_RE.finditer(body) while current:
] if current.name == "p" and "Input:" in current.get_text():
return [] input_pre = current.find_next_sibling("pre")
if input_pre:
input_text = input_pre.get_text().strip()
elif current.name == "p" and "Output:" in current.get_text():
output_pre = current.find_next_sibling("pre")
if output_pre:
output_text = output_pre.get_text().strip()
break
current = current.find_next_sibling()
if not input_text or not output_text:
return None
return (input_text, output_text)
def _extract_problem_info(html: str) -> tuple[int, int, bool]: def scrape(url: str) -> list[tuple[str, str]]:
tm = TIME_RE.search(html) try:
mm = MEM_RE.search(html) headers = {
t = int(round(float(tm.group(1)) * 1000)) if tm else 0 "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
m = int(mm.group(1)) if mm else 0 }
md = MD_BLOCK_RE.search(html)
interactive = False
if md:
body = md.group(1)
interactive = "This is an interactive problem." in body
return t, m, interactive
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
def parse_title(html: str) -> str: soup = BeautifulSoup(response.text, "html.parser")
mt = TITLE_RE.search(html)
return mt.group("title").strip() if mt else ""
test_case = extract_example_test_case(soup)
if not test_case:
return []
def parse_category_from_sidebar(html: str) -> str | None: return [test_case]
m = SIDEBAR_CAT_RE.search(html)
return m.group("cat").strip() if m else None
except Exception as e:
def parse_tests(html: str) -> list[TestCase]: print(f"Error scraping CSES: {e}", file=sys.stderr)
md = MD_BLOCK_RE.search(html)
if not md:
return [] return []
block = md.group(1)
msec = EXAMPLE_SECTION_RE.search(block)
section = msec.group("section") if msec else block
mlabel = LABELED_IO_RE.search(section)
if mlabel:
a = mlabel.group("input").strip()
b = mlabel.group("output").strip()
return [TestCase(input=a, expected=b)]
pres = PRE_RE.findall(section)
if len(pres) >= 2:
return [TestCase(input=pres[0].strip(), expected=pres[1].strip())]
return []
def task_path(problem_id: str | int) -> str: def main() -> None:
return TASK_PATH.format(id=str(problem_id)) if len(sys.argv) < 2:
result: dict[str, str | bool] = {
"success": False,
"error": "Usage: cses.py metadata OR cses.py tests <problem_id_or_url>",
}
print(json.dumps(result))
sys.exit(1)
mode: str = sys.argv[1]
class CSESScraper(BaseScraper): if mode == "metadata":
@property if len(sys.argv) != 2:
def platform_name(self) -> str: result = {
return "cses" "success": False,
"error": "Usage: cses.py metadata",
}
print(json.dumps(result))
sys.exit(1)
async def scrape_contest_metadata(self, contest_id: str) -> MetadataResult: all_categories: dict[str, list[dict[str, str]]] = scrape_all_problems()
async with httpx.AsyncClient() as client:
html = await fetch_text(client, INDEX_PATH) if not all_categories:
problems = parse_category_problems(contest_id, html) result = {
if not problems: "success": False,
return MetadataResult( "error": "Failed to scrape CSES problem categories",
success=False, }
error=f"{self.platform_name}: No problems found for category: {contest_id}", print(json.dumps(result))
url="", sys.exit(1)
)
return MetadataResult( result = {
success=True, "success": True,
error="", "categories": all_categories,
contest_id=contest_id, }
problems=problems, print(json.dumps(result))
url="https://cses.fi/problemset/task/%s",
elif mode == "tests":
if len(sys.argv) != 3:
result = {
"success": False,
"error": "Usage: cses.py tests <problem_id_or_url>",
}
print(json.dumps(result))
sys.exit(1)
problem_input: str = sys.argv[2]
url: str | None = parse_problem_url(problem_input)
if not url:
result = {
"success": False,
"error": f"Invalid problem input: {problem_input}. Use either problem ID (e.g., 1068) or full URL",
"problem_id": problem_input if problem_input.isdigit() else None,
}
print(json.dumps(result))
sys.exit(1)
tests: list[tuple[str, str]] = scrape(url)
problem_id: str = (
problem_input if problem_input.isdigit() else problem_input.split("/")[-1]
) )
async def scrape_contest_list(self) -> ContestListResult: if not tests:
async with httpx.AsyncClient() as client: result = {
html = await fetch_text(client, INDEX_PATH) "success": False,
cats = parse_categories(html) "error": f"No tests found for {problem_input}",
if not cats: "problem_id": problem_id,
return ContestListResult( "url": url,
success=False, error=f"{self.platform_name}: No contests found" }
) print(json.dumps(result))
return ContestListResult(success=True, error="", contests=cats) sys.exit(1)
async def stream_tests_for_category_async(self, category_id: str) -> None: test_list: list[dict[str, str]] = []
async with httpx.AsyncClient( for input_data, output_data in tests:
limits=httpx.Limits(max_connections=CONNECTIONS) test_list.append({"input": input_data, "expected": output_data})
) as client:
index_html = await fetch_text(client, INDEX_PATH)
problems = parse_category_problems(category_id, index_html)
if not problems:
return
sem = asyncio.Semaphore(CONNECTIONS) result = {
"success": True,
"problem_id": problem_id,
"url": url,
"tests": test_list,
}
print(json.dumps(result))
async def run_one(pid: str) -> dict[str, Any]: else:
async with sem: result = {
try: "success": False,
html = await fetch_text(client, task_path(pid)) "error": f"Unknown mode: {mode}. Use 'metadata' or 'tests'",
tests = parse_tests(html) }
timeout_ms, memory_mb, interactive = _extract_problem_info(html) print(json.dumps(result))
except Exception: sys.exit(1)
tests = []
timeout_ms, memory_mb, interactive = 0, 0, False
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": pid,
"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,
}
tasks = [run_one(p.id) for p in problems]
for coro in asyncio.as_completed(tasks):
payload = await coro
print(json.dumps(payload), flush=True)
if __name__ == "__main__": if __name__ == "__main__":
CSESScraper().run_cli() main()

View file

@ -1,72 +0,0 @@
from pydantic import BaseModel, ConfigDict, Field
class TestCase(BaseModel):
input: str
expected: str
model_config = ConfigDict(extra="forbid")
class CombinedTest(BaseModel):
input: str
expected: str
model_config = ConfigDict(extra="forbid")
class ProblemSummary(BaseModel):
id: str
name: str
model_config = ConfigDict(extra="forbid")
class ContestSummary(BaseModel):
id: str
name: str
display_name: str | None = None
model_config = ConfigDict(extra="forbid")
class ScrapingResult(BaseModel):
success: bool
error: str
model_config = ConfigDict(extra="forbid")
class MetadataResult(ScrapingResult):
contest_id: str = ""
problems: list[ProblemSummary] = Field(default_factory=list)
url: str
model_config = ConfigDict(extra="forbid")
class ContestListResult(ScrapingResult):
contests: list[ContestSummary] = Field(default_factory=list)
model_config = ConfigDict(extra="forbid")
class TestsResult(ScrapingResult):
problem_id: str
combined: CombinedTest
tests: list[TestCase] = Field(default_factory=list)
timeout_ms: int
memory_mb: float
interactive: bool = False
multi_test: bool = False
model_config = ConfigDict(extra="forbid")
class ScraperConfig(BaseModel):
timeout_seconds: int = 30
max_retries: int = 3
backoff_base: float = 2.0
rate_limit_delay: float = 1.0
model_config = ConfigDict(extra="forbid")

View file

@ -1,58 +0,0 @@
#!/usr/bin/env python3
import asyncio
import shlex
import sys
from collections.abc import Sequence
async def pump(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter | None
) -> None:
while True:
data = await reader.readline()
if not data:
break
_ = sys.stdout.buffer.write(data)
_ = sys.stdout.flush()
if writer:
writer.write(data)
await writer.drain()
async def main(interactor_cmd: Sequence[str], interactee_cmd: Sequence[str]) -> None:
interactor = await asyncio.create_subprocess_exec(
*interactor_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)
interactee = await asyncio.create_subprocess_exec(
*interactee_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
)
assert (
interactor.stdout
and interactor.stdin
and interactee.stdout
and interactee.stdin
)
tasks = [
asyncio.create_task(pump(interactor.stdout, interactee.stdin)),
asyncio.create_task(pump(interactee.stdout, interactor.stdin)),
]
_ = await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
_ = await interactor.wait()
_ = await interactee.wait()
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: interact.py <interactor> <interactee>", file=sys.stderr)
sys.exit(1)
interactor_cmd = shlex.split(sys.argv[1])
interactee_cmd = shlex.split(sys.argv[2])
_ = asyncio.run(main(interactor_cmd, interactee_cmd))

View file

@ -1 +1 @@
std = 'vim' std = "vim"

7
spec/plugin_spec.lua Normal file
View file

@ -0,0 +1,7 @@
local cp = require('cp')
describe('neovim plugin', function()
it('work as expect', function()
cp.setup()
end)
end)

View file

@ -1,275 +0,0 @@
import asyncio
import importlib.util
import io
import json
import sys
from pathlib import Path
from types import SimpleNamespace
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"
@pytest.fixture
def fixture_text():
def _load(name: str) -> str:
p = FIX / name
return p.read_text(encoding="utf-8")
return _load
def _load_scraper_module(module_path: Path, module_name: str):
spec = importlib.util.spec_from_file_location(
f"scrapers.{module_name}", module_path
)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load module {module_name}")
module = importlib.util.module_from_spec(spec)
sys.modules[f"scrapers.{module_name}"] = module
spec.loader.exec_module(module)
return module
def _capture_stdout(coro):
buf = io.StringIO()
old = sys.stdout
sys.stdout = buf
try:
rc = asyncio.run(coro)
out = buf.getvalue()
finally:
sys.stdout = old
return rc, out
@pytest.fixture
def run_scraper_offline(fixture_text):
def _router_cses(*, path: str | None = None, url: str | None = None) -> str:
if not path and not url:
raise AssertionError("CSES expects path or url")
target = path or url
if target is None:
raise AssertionError(f"No target for CSES (path={path!r}, url={url!r})")
if target.startswith("https://cses.fi"):
target = target.removeprefix("https://cses.fi")
if target.strip("/") == "problemset":
return fixture_text("cses/contests.html")
if target.startswith("/problemset/task/") or target.startswith(
"problemset/task/"
):
pid = target.rstrip("/").split("/")[-1]
return fixture_text(f"cses/task_{pid}.html")
raise AssertionError(f"No fixture for CSES path={path!r} url={url!r}")
def _router_atcoder(*, path: str | None = None, url: str | None = None) -> str:
if not url:
raise AssertionError("AtCoder expects url routing")
if "/contests/archive" in url:
return fixture_text("atcoder/contests.html")
if url.endswith("/tasks"):
return fixture_text("atcoder/abc100_tasks.html")
if "/tasks/" in url:
slug = url.rsplit("/", 1)[-1]
return fixture_text(f"atcoder/task_{slug}.html")
raise AssertionError(f"No fixture for AtCoder url={url!r}")
def _router_codeforces(*, path: str | None = None, url: str | None = None) -> str:
if not url:
raise AssertionError("Codeforces expects url routing")
if "/contest/" in url and url.endswith("/problems"):
contest_id = url.rstrip("/").split("/")[-2]
return fixture_text(f"codeforces/{contest_id}_problems.html")
if "/contests" in url and "/problem/" not in url:
return fixture_text("codeforces/contests.html")
if "/problem/" in url:
parts = url.rstrip("/").split("/")
contest_id, index = parts[-3], parts[-1]
return fixture_text(f"codeforces/{contest_id}_{index}.html")
if "/problemset/problem/" in url:
parts = url.rstrip("/").split("/")
contest_id, index = parts[-2], parts[-1]
return fixture_text(f"codeforces/{contest_id}_{index}.html")
raise AssertionError(f"No fixture for Codeforces url={url!r}")
def _make_offline_fetches(scraper_name: str):
match scraper_name:
case "cses":
async def __offline_fetch_text(client, path: str, **kwargs):
html = _router_cses(path=path)
return SimpleNamespace(
text=html,
status_code=200,
raise_for_status=lambda: None,
)
return {
"__offline_fetch_text": __offline_fetch_text,
}
case "atcoder":
def __offline_fetch(url: str, *args, **kwargs):
html = _router_atcoder(url=url)
return html
async def __offline_get_async(client, url: str, **kwargs):
return _router_atcoder(url=url)
return {
"_fetch": __offline_fetch,
"_get_async": __offline_get_async,
}
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_requests_get(url: str, **kwargs):
if "api/contest.list" in url:
data = {
"status": "OK",
"result": [
{
"id": 1550,
"name": "Educational Codeforces Round 155 (Rated for Div. 2)",
"phase": "FINISHED",
},
{
"id": 1000,
"name": "Codeforces Round #1000",
"phase": "FINISHED",
},
],
}
class R:
def json(self_inner):
return data
def raise_for_status(self_inner):
return None
return R()
raise AssertionError(f"Unexpected requests.get call: {url}")
return {
"curl_requests.get": _mock_curl_get,
"requests.get": _mock_requests_get,
}
case "codechef":
class MockResponse:
def __init__(self, json_data):
self._json_data = json_data
self.status_code = 200
def json(self):
return self._json_data
def raise_for_status(self):
pass
async def __offline_get_async(client, url: str, **kwargs):
if "/api/list/contests/all" in url:
data = json.loads(fixture_text("codechef/contests.json"))
return MockResponse(data)
if "/api/contests/START" in url and "/problems/" not in url:
contest_id = url.rstrip("/").split("/")[-1]
try:
data = json.loads(
fixture_text(f"codechef/{contest_id}.json")
)
return MockResponse(data)
except FileNotFoundError:
raise AssertionError(f"No fixture for CodeChef url={url!r}")
if "/api/contests/START" in url and "/problems/" in url:
parts = url.rstrip("/").split("/")
contest_id = parts[-3]
problem_id = parts[-1]
data = json.loads(
fixture_text(f"codechef/{contest_id}_{problem_id}.json")
)
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 _:
raise AssertionError(f"Unknown scraper: {scraper_name}")
scraper_classes = {
"cses": "CSESScraper",
"atcoder": "AtcoderScraper",
"codeforces": "CodeforcesScraper",
"codechef": "CodeChefScraper",
}
def _run(scraper_name: str, mode: str, *args: str):
mod_path = ROOT / "scrapers" / f"{scraper_name}.py"
ns = _load_scraper_module(mod_path, scraper_name)
offline_fetches = _make_offline_fetches(scraper_name)
if scraper_name == "codeforces":
curl_requests.get = offline_fetches["curl_requests.get"]
requests.get = offline_fetches["requests.get"]
elif scraper_name == "atcoder":
ns._fetch = offline_fetches["_fetch"]
ns._get_async = offline_fetches["_get_async"]
elif scraper_name == "cses":
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()
argv = [str(mod_path), mode, *args]
rc, out = _capture_stdout(scraper._run_cli_async(argv))
json_lines: list[Any] = []
for line in (_line for _line in out.splitlines() if _line.strip()):
json_lines.append(json.loads(line))
return rc, json_lines
return _run

View file

@ -1,519 +0,0 @@
<!doctype html>
<html>
<head>
<title>Tasks - AtCoder Beginner Contest 100</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="Tasks - AtCoder Beginner Contest 100" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta
property="twitter:title"
content="Tasks - AtCoder Beginner Contest 100"
/>
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'q+4tZ4tLQh/4nobcpVAuiml6OVEZOdZDZURhPenxPbc='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks"
>
<input
type="hidden"
name="csrf_token"
value="q&#43;4tZ4tLQh/4nobcpVAuiml6OVEZOdZDZURhPenxPbc="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<h2>Tasks</h2>
<hr />
<div class="panel panel-default table-responsive">
<table class="table table-bordered table-striped">
<thead>
<tr>
<th width="3%" class="text-center"></th>
<th>Task Name</th>
<th width="10%" class="text-right no-break">Time Limit</th>
<th width="10%" class="text-right no-break">
Memory Limit
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_a">A</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_a"
>Happy Birthday!</a
>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_b">B</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_b"
>Ringo&#39;s Favorite Numbers</a
>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_c">C</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_c">*3 or /2</a>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
<tr>
<td class="text-center no-break">
<a href="/contests/abc100/tasks/abc100_d">D</a>
</td>
<td>
<a href="/contests/abc100/tasks/abc100_d"
>Patisserie ABC</a
>
</td>
<td class="text-right">2 sec</td>
<td class="text-right">976 MiB</td>
</tr>
</tbody>
</table>
</div>
<p class="btn-text-group">
<a class="btn-text" href="/contests/abc100/tasks_print"
>Tasks for printing</a
>
</p>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks?lang=en"
data-a2a-title="Tasks - AtCoder Beginner Contest 100"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,885 +0,0 @@
<!doctype html>
<html>
<head>
<title>A - Happy Birthday!</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="A - Happy Birthday!" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks/abc100_a"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta property="twitter:title" content="A - Happy Birthday!" />
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'RRHWPnDZqM+tdZpgbKRjH5FxiX5spw9S3/HKRbnyqok='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2.min.css"
rel="stylesheet"
/>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2-bootstrap.min.css"
rel="stylesheet"
/>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/select2.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ace.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ext-language_tools.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/run_prettify.js"></script>
<link
rel="stylesheet"
href="//img.atcoder.jp/public/6372bb3/css/cdn/katex.min.css"
/>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/katex.min.js"
></script>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/auto-render.min.js"
></script>
<script>
$(function () {
$('var').each(function () {
var html = $(this)
.html()
.replace(/<sub>/g, '_{')
.replace(/<\/sub>/g, '}')
$(this).html('\\(' + html + '\\)')
})
})
</script>
<script>
var katexOptions = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
ignoredTags: [
'script',
'noscript',
'style',
'textarea',
'code',
'option'
],
ignoredClasses: ['prettyprint', 'source-code-for-copy'],
throwOnError: false
}
document.addEventListener('DOMContentLoaded', function () {
renderMathInElement(document.body, katexOptions)
})
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks/abc100_a?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks/abc100_a?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_a"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_a"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_a"
>
<input
type="hidden"
name="csrf_token"
value="RRHWPnDZqM&#43;tdZpgbKRjH5FxiX5spw9S3/HKRbnyqok="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<span class="h2">
A - Happy Birthday!
<a
class="btn btn-default btn-sm"
href="/contests/abc100/tasks/abc100_a/editorial"
>Editorial</a
>
</span>
<span id="task-lang-btn" class="pull-right"
><span data-lang="ja"
><img src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/></span>
/
<span data-lang="en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" /></span
></span>
<script>
$(function () {
var ts = $('#task-statement span.lang')
if (ts.children('span').size() <= 1) {
$('#task-lang-btn').hide()
ts.children('span').show()
return
}
var REMEMBER_LB = 5
var LS_KEY = 'task_lang'
var taskLang = getLS(LS_KEY) || ''
function isTaskLangSet(taskLang) {
return taskLang === 'ja' || taskLang === 'en'
}
if (isTaskLangSet(taskLang)) {
const params = new URLSearchParams(window.location.search)
if (params.get('lang')) {
setLS(LS_KEY, REMEMBER_LB)
taskLang = LANG
}
} else {
taskLang = LANG
}
ts.children('span.lang-' + taskLang).show()
$('#task-lang-btn span').click(function () {
var l = $(this).data('lang')
ts.children('span').hide()
ts.children('span.lang-' + l).show()
taskLang = getLS(LS_KEY) || ''
let changeTimes = 0
if (isTaskLangSet(taskLang)) {
changeTimes = REMEMBER_LB
} else {
changeTimes = parseInt(taskLang, 10)
if (isNaN(changeTimes)) changeTimes = 0
changeTimes++
}
if (changeTimes < REMEMBER_LB) setLS(LS_KEY, changeTimes)
else setLS(LS_KEY, l)
})
})
</script>
<hr />
<p>Time Limit: 2 sec / Memory Limit: 976 MiB</p>
<div id="task-statement">
<span class="lang">
<span class="lang-ja">
<p>配点: <var>100</var></p>
<div class="part">
<section>
<h3>問題文</h3>
<p>
もうすぐ E869120 君と square1001 君の
<var>16</var> 才の誕生日が来る.<br />
そこで, AtCoder 王国の高橋君は, 円形のケーキ
<var>1</var> 個に放射状に切れ目を入れ
<var>16</var> 等分したものを, 彼らにプレゼントした.
</p>
<p>
E869120 君はそのうち <var>A</var> 切れ、square1001 君は
<var>B</var> 切れを食べようとした.<br />
しかし, ケーキと一緒についていた紙を見ると,
「同じ人が隣り合う
<var>2</var>
切れのケーキを両方取ってはならない」と書かれていた.
</p>
<p>
さて、彼らは紙に書かれたことを守って、<var>2</var>
人とも食べたい数のケーキを取ることができるだろうか?
</p>
</section>
</div>
<div class="part">
<section>
<h3>制約</h3>
<ul>
<li>
<var>A, B</var><var>1</var> 以上
<var>16</var> 以下の整数
</li>
<li><var>A+B</var><var>16</var> 以下である.</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>入力</h3>
<p>入力は以下の形式で標準入力から与えられる.</p>
<pre><var>A</var> <var>B</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>出力</h3>
<p>
紙に書かれたことを守って, E869120 君と square1001
君両方が, 食べたい数のケーキを取ることができるならば
<code>Yay!</code>, そうでなければ
<code>:(</code> と出力しなさい.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 1</h3>
<pre>
5 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 1</h3>
<pre>
Yay!
</pre
>
<p>
下の図のようにケーキを取れば、<var>2</var>
人とも目標を達成することができる.<br />
<img
alt=" "
src="https://img.atcoder.jp/abc100/e87fa456a900ac9ae36671ae8bd5eeea.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 2</h3>
<pre>
8 8
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 2</h3>
<pre>
Yay!
</pre
>
<p>
下の図のようにケーキを取れば、<var>2</var>
人とも目標を達成することができる.<br />
<img
alt=" "
src="https://img.atcoder.jp/abc100/a7989ac033e6ba86e14078864c20d9c5.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 3</h3>
<pre>
11 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 3</h3>
<pre>
:(
</pre
>
<p>
この場合, 残念ながら目標を達成する方法は
<var>1</var> つもない.
</p>
</section>
</div>
</span>
<span class="lang-en">
<p>Score: <var>100</var> points</p>
<div class="part">
<section>
<h3>Problem Statement</h3>
<p>
E869120's and square1001's <var>16</var>-th birthday is
coming soon.<br />
Takahashi from AtCoder Kingdom gave them a round cake
cut into <var>16</var> equal fan-shaped pieces.
</p>
<p>
E869120 and square1001 were just about to eat
<var>A</var> and <var>B</var> of those pieces,
respectively,<br />
when they found a note attached to the cake saying that
"the same person should not take two adjacent pieces of
cake".
</p>
<p>
Can both of them obey the instruction in the note and
take desired numbers of pieces of cake?
</p>
</section>
</div>
<div class="part">
<section>
<h3>Constraints</h3>
<ul>
<li>
<var>A</var> and <var>B</var> are integers between
<var>1</var> and <var>16</var> (inclusive).
</li>
<li><var>A+B</var> is at most <var>16</var>.</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>Input</h3>
<p>
Input is given from Standard Input in the following
format:
</p>
<pre><var>A</var> <var>B</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>Output</h3>
<p>
If both E869120 and square1001 can obey the
instruction in the note and take desired numbers of
pieces of cake, print <code>Yay!</code>; otherwise,
print <code>:(</code>.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 1</h3>
<pre>
5 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 1</h3>
<pre>
Yay!
</pre
>
<p>
Both of them can take desired number of pieces as
follows:
<img
alt=" "
src="https://img.atcoder.jp/abc100/e87fa456a900ac9ae36671ae8bd5eeea.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 2</h3>
<pre>
8 8
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 2</h3>
<pre>
Yay!
</pre
>
<p>
Both of them can take desired number of pieces as
follows:
<img
alt=" "
src="https://img.atcoder.jp/abc100/a7989ac033e6ba86e14078864c20d9c5.png"
/>
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 3</h3>
<pre>
11 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 3</h3>
<pre>
:(
</pre
>
<p>
In this case, there is no way for them to take desired
number of pieces, unfortunately.
</p>
</section>
</div>
</span>
</span>
</div>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks/abc100_a?lang=en"
data-a2a-title="A - Happy Birthday!"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

View file

@ -1,887 +0,0 @@
<!doctype html>
<html>
<head>
<title>B - Ringo&#39;s Favorite Numbers</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="B - Ringo&#39;s Favorite Numbers" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks/abc100_b"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta property="twitter:title" content="B - Ringo&#39;s Favorite Numbers" />
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'jcqZoHtgdlDPapU/uheo04+cw+2+EssGGNrF7tJr004='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2.min.css"
rel="stylesheet"
/>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2-bootstrap.min.css"
rel="stylesheet"
/>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/select2.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ace.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ext-language_tools.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/run_prettify.js"></script>
<link
rel="stylesheet"
href="//img.atcoder.jp/public/6372bb3/css/cdn/katex.min.css"
/>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/katex.min.js"
></script>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/auto-render.min.js"
></script>
<script>
$(function () {
$('var').each(function () {
var html = $(this)
.html()
.replace(/<sub>/g, '_{')
.replace(/<\/sub>/g, '}')
$(this).html('\\(' + html + '\\)')
})
})
</script>
<script>
var katexOptions = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
ignoredTags: [
'script',
'noscript',
'style',
'textarea',
'code',
'option'
],
ignoredClasses: ['prettyprint', 'source-code-for-copy'],
throwOnError: false
}
document.addEventListener('DOMContentLoaded', function () {
renderMathInElement(document.body, katexOptions)
})
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks/abc100_b?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks/abc100_b?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_b"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_b"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_b"
>
<input
type="hidden"
name="csrf_token"
value="jcqZoHtgdlDPapU/uheo04&#43;cw&#43;2&#43;EssGGNrF7tJr004="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<span class="h2">
B - Ringo&#39;s Favorite Numbers
<a
class="btn btn-default btn-sm"
href="/contests/abc100/tasks/abc100_b/editorial"
>Editorial</a
>
</span>
<span id="task-lang-btn" class="pull-right"
><span data-lang="ja"
><img src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/></span>
/
<span data-lang="en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" /></span
></span>
<script>
$(function () {
var ts = $('#task-statement span.lang')
if (ts.children('span').size() <= 1) {
$('#task-lang-btn').hide()
ts.children('span').show()
return
}
var REMEMBER_LB = 5
var LS_KEY = 'task_lang'
var taskLang = getLS(LS_KEY) || ''
function isTaskLangSet(taskLang) {
return taskLang === 'ja' || taskLang === 'en'
}
if (isTaskLangSet(taskLang)) {
const params = new URLSearchParams(window.location.search)
if (params.get('lang')) {
setLS(LS_KEY, REMEMBER_LB)
taskLang = LANG
}
} else {
taskLang = LANG
}
ts.children('span.lang-' + taskLang).show()
$('#task-lang-btn span').click(function () {
var l = $(this).data('lang')
ts.children('span').hide()
ts.children('span.lang-' + l).show()
taskLang = getLS(LS_KEY) || ''
let changeTimes = 0
if (isTaskLangSet(taskLang)) {
changeTimes = REMEMBER_LB
} else {
changeTimes = parseInt(taskLang, 10)
if (isNaN(changeTimes)) changeTimes = 0
changeTimes++
}
if (changeTimes < REMEMBER_LB) setLS(LS_KEY, changeTimes)
else setLS(LS_KEY, l)
})
})
</script>
<hr />
<p>Time Limit: 2 sec / Memory Limit: 976 MiB</p>
<div id="task-statement">
<span class="lang">
<span class="lang-ja">
<p>配点: <var>200</var></p>
<div class="part">
<section>
<h3>問題文</h3>
<p>
今日は, 記念すべき AtCoder Beginner Contest 100
が開催される. そのため, 高橋君はりんごさんに,
ある整数をプレゼントしようと思った.<br />
今日のコンテストは「AtCoder Beginner Contest
100」なので, りんごさんは <var>100</var>
<strong>ちょうど</strong>
<var>D</var>
回割りきれる正の整数をプレゼントされると喜ぶ.
</p>
<p>
さて, りんごさんがプレゼントされると喜ぶような整数のうち
<var>N</var> 番目に小さいものを求めなさい.
</p>
</section>
</div>
<div class="part">
<section>
<h3>制約</h3>
<ul>
<li>
<var>D</var><var>0, 1, 2</var> のいずれかである
</li>
<li>
<var>N</var><var>1</var> 以上
<var>100</var> 以下の整数
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>入力</h3>
<p>入力は以下の形式で標準入力から与えられる.</p>
<pre><var>D</var> <var>N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>出力</h3>
<p>
<var>100</var> でちょうど
<var>D</var> 回割りきれる正の整数の中で
<var>N</var> 番目に小さいものを出力しなさい.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 1</h3>
<pre>
0 5
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 1</h3>
<pre>
5
</pre
>
<p>
<var>100</var> でちょうど
<var>0</var> 回割り切れる(すなわち,
<var>100</var> で割り切れない)整数は, <var>1</var>,
<var>2</var>, <var>3</var>, <var>4</var>, <var>5</var>,
<var>6</var>, <var>7</var>, ... と続く.<br />
よって, <var>5</var> 番目に小さいりんごさんが喜ぶ整数は
<var>5</var> である.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 2</h3>
<pre>
1 11
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 2</h3>
<pre>
1100
</pre
>
<p>
<var>100</var> でちょうど
<var>1</var> 回割り切れる整数は, <var>100</var>,
<var>200</var>, <var>300</var>, <var>400</var>,
<var>500</var>, <var>600</var>, <var>700</var>,
<var>800</var>, <var>900</var>, <var>1 \ 000</var>,
<var>1 \ 100</var>, ... と続く.<br />
よって, 求めたい整数は <var>1 \ 100</var> である.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 3</h3>
<pre>
2 85
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 3</h3>
<pre>
850000
</pre
>
<p>
<var>100</var> でちょうど
<var>2</var> 回割り切れる整数は, <var>10 \ 000</var>,
<var>20 \ 000</var>, <var>30 \ 000</var>, ... と続く.<br />
よって, 求めたい整数は <var>850 \ 000</var> である.
</p>
</section>
</div>
</span>
<span class="lang-en">
<p>Score: <var>200</var> points</p>
<div class="part">
<section>
<h3>Problem Statement</h3>
<p>
Today, the memorable AtCoder Beginner Contest 100 takes
place. On this occasion, Takahashi would like to give an
integer to Ringo.<br />
As the name of the contest is AtCoder Beginner Contest
100, Ringo would be happy if he is given a positive
integer that can be divided by <var>100</var>
<strong>exactly</strong> <var>D</var> times.
</p>
<p>
Find the <var>N</var>-th smallest integer that would
make Ringo happy.
</p>
</section>
</div>
<div class="part">
<section>
<h3>Constraints</h3>
<ul>
<li>
<var>D</var> is <var>0</var>, <var>1</var> or
<var>2</var>.
</li>
<li>
<var>N</var> is an integer between <var>1</var> and
<var>100</var> (inclusive).
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>Input</h3>
<p>
Input is given from Standard Input in the following
format:
</p>
<pre><var>D</var> <var>N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>Output</h3>
<p>
Print the <var>N</var>-th smallest integer that can be
divided by <var>100</var> exactly <var>D</var> times.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 1</h3>
<pre>
0 5
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 1</h3>
<pre>
5
</pre
>
<p>
The integers that can be divided by
<var>100</var> exactly <var>0</var> times (that is, not
divisible by <var>100</var>) are as follows:
<var>1</var>, <var>2</var>, <var>3</var>, <var>4</var>,
<var>5</var>, <var>6</var>, <var>7</var>, ...<br />
Thus, the <var>5</var>-th smallest integer that would
make Ringo happy is <var>5</var>.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 2</h3>
<pre>
1 11
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 2</h3>
<pre>
1100
</pre
>
<p>
The integers that can be divided by
<var>100</var> exactly once are as follows:
<var>100</var>, <var>200</var>, <var>300</var>,
<var>400</var>, <var>500</var>, <var>600</var>,
<var>700</var>, <var>800</var>, <var>900</var>,
<var>1 \ 000</var>, <var>1 \ 100</var>, ...<br />
Thus, the integer we are seeking is <var>1 \ 100</var>.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 3</h3>
<pre>
2 85
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 3</h3>
<pre>
850000
</pre
>
<p>
The integers that can be divided by
<var>100</var> exactly twice are as follows:
<var>10 \ 000</var>, <var>20 \ 000</var>,
<var>30 \ 000</var>, ...<br />
Thus, the integer we are seeking is
<var>850 \ 000</var>.
</p>
</section>
</div>
</span>
</span>
</div>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks/abc100_b?lang=en"
data-a2a-title="B - Ringo&#39;s Favorite Numbers"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

View file

@ -1,904 +0,0 @@
<!doctype html>
<html>
<head>
<title>C - *3 or /2</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="Content-Language" content="en" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta
name="google-site-verification"
content="nXGC_JxO0yoP1qBzMnYD_xgufO6leSLw1kyNo2HZltM"
/>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-RC512FD18N"
></script>
<script>
window.dataLayer = window.dataLayer || []
function gtag() {
dataLayer.push(arguments)
}
gtag('js', new Date())
gtag('set', 'user_properties', {
login_status: 'logged_out'
})
gtag('config', 'G-RC512FD18N')
</script>
<meta
name="description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta name="author" content="AtCoder Inc." />
<meta property="og:site_name" content="AtCoder" />
<meta property="og:title" content="C - *3 or /2" />
<meta
property="og:description"
content="AtCoder is a programming contest site for anyone from beginners to experts. We hold weekly programming contests online."
/>
<meta property="og:type" content="website" />
<meta
property="og:url"
content="https://atcoder.jp/contests/abc100/tasks/abc100_c"
/>
<meta
property="og:image"
content="https://img.atcoder.jp/assets/atcoder.png"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@atcoder" />
<meta property="twitter:title" content="C - *3 or /2" />
<link
href="//fonts.googleapis.com/css?family=Lato:400,700"
rel="stylesheet"
type="text/css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/bootstrap.min.css"
/>
<link
rel="stylesheet"
type="text/css"
href="//img.atcoder.jp/public/6372bb3/css/base.css"
/>
<link
rel="shortcut icon"
type="image/png"
href="//img.atcoder.jp/assets/favicon.png"
/>
<link rel="apple-touch-icon" href="//img.atcoder.jp/assets/atcoder.png" />
<script src="//img.atcoder.jp/public/6372bb3/js/lib/jquery-1.9.1.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/bootstrap.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/js.cookie.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/moment_js-ja.js"></script>
<script>
var LANG = 'en'
var userScreenName = ''
var csrfToken = 'KwoiS7wTPLvccvgUDoQZ6H++fkjXMCchJrW6/YFqOJM='
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/utils.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/contest.js"></script>
<link
href="//img.atcoder.jp/public/6372bb3/css/contest.css"
rel="stylesheet"
/>
<script>
var contestScreenName = 'abc100'
var remainingText = 'Remaining Time'
var countDownText = 'Contest begins in'
var startTime = moment('2018-06-16T21:00:00+09:00')
var endTime = moment('2018-06-16T22:40:00+09:00')
</script>
<style></style>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2.min.css"
rel="stylesheet"
/>
<link
href="//img.atcoder.jp/public/6372bb3/css/cdn/select2-bootstrap.min.css"
rel="stylesheet"
/>
<script src="//img.atcoder.jp/public/6372bb3/js/lib/select2.min.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ace.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/ace/ext-language_tools.js"></script>
<script src="//img.atcoder.jp/public/6372bb3/js/cdn/run_prettify.js"></script>
<link
rel="stylesheet"
href="//img.atcoder.jp/public/6372bb3/css/cdn/katex.min.css"
/>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/katex.min.js"
></script>
<script
defer
src="//img.atcoder.jp/public/6372bb3/js/cdn/auto-render.min.js"
></script>
<script>
$(function () {
$('var').each(function () {
var html = $(this)
.html()
.replace(/<sub>/g, '_{')
.replace(/<\/sub>/g, '}')
$(this).html('\\(' + html + '\\)')
})
})
</script>
<script>
var katexOptions = {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true }
],
ignoredTags: [
'script',
'noscript',
'style',
'textarea',
'code',
'option'
],
ignoredClasses: ['prettyprint', 'source-code-for-copy'],
throwOnError: false
}
document.addEventListener('DOMContentLoaded', function () {
renderMathInElement(document.body, katexOptions)
})
</script>
<script src="//img.atcoder.jp/public/6372bb3/js/base.js"></script>
</head>
<body>
<script type="text/javascript">
var __pParams = __pParams || []
__pParams.push({
client_id: '468',
c_1: 'atcodercontest',
c_2: 'ClientSite'
})
</script>
<script
type="text/javascript"
src="https://cdn.d2-apps.net/js/tr.js"
async
></script>
<div
id="modal-contest-start"
class="modal fade"
tabindex="-1"
role="dialog"
>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest started</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has begun.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="modal-contest-end" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title">Contest is over</h4>
</div>
<div class="modal-body">
<p>AtCoder Beginner Contest 100 has ended.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">
Close
</button>
</div>
</div>
</div>
</div>
<div id="main-div" class="float-container">
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container-fluid">
<div class="navbar-header">
<button
type="button"
class="navbar-toggle collapsed"
data-toggle="collapse"
data-target="#navbar-collapse"
aria-expanded="false"
>
<span class="icon-bar"></span><span class="icon-bar"></span
><span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/home"></a>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li>
<a class="contest-title" href="/contests/abc100"
>AtCoder Beginner Contest 100</a
>
</li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
>
<img src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" />
English <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/tasks/abc100_c?lang=ja"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/>
日本語</a
>
</li>
<li>
<a href="/contests/abc100/tasks/abc100_c?lang=en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png"
/>
English</a
>
</li>
</ul>
</li>
<li>
<a
href="/register?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_c"
>Sign Up</a
>
</li>
<li>
<a
href="/login?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_c"
>Sign In</a
>
</li>
</ul>
</div>
</div>
</nav>
<form
method="POST"
name="form_logout"
action="/logout?continue=https%3A%2F%2Fatcoder.jp%2Fcontests%2Fabc100%2Ftasks%2Fabc100_c"
>
<input
type="hidden"
name="csrf_token"
value="KwoiS7wTPLvccvgUDoQZ6H&#43;&#43;fkjXMCchJrW6/YFqOJM="
/>
</form>
<div id="main-container" class="container" style="padding-top: 50px">
<div class="row">
<div id="contest-nav-tabs" class="col-sm-12 mb-2 cnvtb-fixed">
<div>
<small class="contest-duration">
Contest Duration:
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2100&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 21:00:00+0900</time
></a
>
-
<a
href="http://www.timeanddate.com/worldclock/fixedtime.html?iso=20180616T2240&p1=248"
target="blank"
><time class="fixtime fixtime-full"
>2018-06-16 22:40:00+0900</time
></a
>
(local time) (100 minutes)
</small>
<small class="back-to-home pull-right"
><a href="/home">Back to Home</a></small
>
</div>
<ul class="nav nav-tabs">
<li>
<a href="/contests/abc100"
><span
class="glyphicon glyphicon-home"
aria-hidden="true"
></span>
Top</a
>
</li>
<li class="active">
<a href="/contests/abc100/tasks"
><span
class="glyphicon glyphicon-tasks"
aria-hidden="true"
></span>
Tasks</a
>
</li>
<li>
<a href="/contests/abc100/clarifications"
><span
class="glyphicon glyphicon-question-sign"
aria-hidden="true"
></span>
Clarifications <span id="clar-badge" class="badge"></span
></a>
</li>
<li>
<a
class="dropdown-toggle"
data-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
><span
class="glyphicon glyphicon-list"
aria-hidden="true"
></span>
Results<span class="caret"></span
></a>
<ul class="dropdown-menu">
<li>
<a href="/contests/abc100/submissions"
><span
class="glyphicon glyphicon-globe"
aria-hidden="true"
></span>
All Submissions</a
>
</li>
</ul>
</li>
<li>
<a href="/contests/abc100/standings"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Standings</a
>
</li>
<li>
<a href="/contests/abc100/standings/virtual"
><span
class="glyphicon glyphicon-sort-by-attributes-alt"
aria-hidden="true"
></span>
Virtual Standings</a
>
</li>
<li>
<a href="/contests/abc100/editorial"
><span
class="glyphicon glyphicon-book"
aria-hidden="true"
></span>
Editorial</a
>
</li>
<li class="pull-right">
<a id="fix-cnvtb" href="javascript:void(0)"
><span
class="glyphicon glyphicon-pushpin"
aria-hidden="true"
></span
></a>
</li>
</ul>
</div>
<div class="col-sm-12">
<span class="h2">
C - *3 or /2
<a
class="btn btn-default btn-sm"
href="/contests/abc100/tasks/abc100_c/editorial"
>Editorial</a
>
</span>
<span id="task-lang-btn" class="pull-right"
><span data-lang="ja"
><img src="//img.atcoder.jp/assets/top/img/flag-lang/ja.png"
/></span>
/
<span data-lang="en"
><img
src="//img.atcoder.jp/assets/top/img/flag-lang/en.png" /></span
></span>
<script>
$(function () {
var ts = $('#task-statement span.lang')
if (ts.children('span').size() <= 1) {
$('#task-lang-btn').hide()
ts.children('span').show()
return
}
var REMEMBER_LB = 5
var LS_KEY = 'task_lang'
var taskLang = getLS(LS_KEY) || ''
function isTaskLangSet(taskLang) {
return taskLang === 'ja' || taskLang === 'en'
}
if (isTaskLangSet(taskLang)) {
const params = new URLSearchParams(window.location.search)
if (params.get('lang')) {
setLS(LS_KEY, REMEMBER_LB)
taskLang = LANG
}
} else {
taskLang = LANG
}
ts.children('span.lang-' + taskLang).show()
$('#task-lang-btn span').click(function () {
var l = $(this).data('lang')
ts.children('span').hide()
ts.children('span.lang-' + l).show()
taskLang = getLS(LS_KEY) || ''
let changeTimes = 0
if (isTaskLangSet(taskLang)) {
changeTimes = REMEMBER_LB
} else {
changeTimes = parseInt(taskLang, 10)
if (isNaN(changeTimes)) changeTimes = 0
changeTimes++
}
if (changeTimes < REMEMBER_LB) setLS(LS_KEY, changeTimes)
else setLS(LS_KEY, l)
})
})
</script>
<hr />
<p>Time Limit: 2 sec / Memory Limit: 976 MiB</p>
<div id="task-statement">
<span class="lang">
<span class="lang-ja">
<p>配点: <var>300</var></p>
<div class="part">
<section>
<h3>問題文</h3>
<p>
AtCoder Beginner Contest 100 の開催にともなって, AtCoder
社では長さ <var>N</var> の数列 <var>a = </var>{<var
>a_1, a_2, a_3, ..., a_N</var
>} が飾られることになった. <br />
社員のすぬけ君は, この数列で遊んでみようと思った.
</p>
<p>
具体的には,
以下の操作をできるだけ多くの回数繰り返そうと思った.
</p>
<pre><var>1 \leq i \leq N</var> を満たす全ての <var>i</var> に対して, それぞれ「<var>a_i</var> の値を <var>2</var> で割る」「<var>a_i</var> の値を <var>3</var> 倍する」のどちらかを行う.
ただし, 全ての <var>i</var> に対して <var>3</var> 倍することはできず, 操作後の <var>a_i</var> の値は整数でなければならない.
</pre>
<p>最大で何回の操作が可能か, 求めなさい.</p>
</section>
</div>
<div class="part">
<section>
<h3>制約</h3>
<ul>
<li>
<var>N</var><var>1</var> 以上
<var>10 \ 000</var> 以下の整数
</li>
<li>
<var>a_i</var><var>1</var> 以上
<var>1 \ 000 \ 000 \ 000</var> 以下の整数
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>入力</h3>
<p>入力は以下の形式で標準入力から与えられる.</p>
<pre><var>N</var>
<var>a_1</var> <var>a_2</var> <var>a_3</var> <var>...</var> <var>a_N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>出力</h3>
<p>すぬけ君が行える最大の操作回数を出力しなさい.</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 1</h3>
<pre>
3
5 2 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 1</h3>
<pre>
3
</pre
>
<p>
最初, 数列は <var>{5, 2, 4}</var> であるが,
以下のように操作すれば
<var>3</var> 回の操作を行うことができる.
</p>
<ul>
<li>
最初に, <var>a_1</var><var>3</var> 倍し,
<var>a_2</var><var>3</var> 倍し, <var>a_3</var>
<var>2</var> で割る. すると数列は
<var>{15, 6, 2}</var> となる.
</li>
<li>
次に, <var>a_1</var><var>3</var> 倍し,
<var>a_2</var><var>2</var> で割り,
<var>a_3</var><var>3</var> 倍する. すると数列は
<var>{45, 3, 6}</var> となる.
</li>
<li>
最後に, <var>a_1</var><var>3</var> 倍し,
<var>a_2</var><var>3</var> 倍し, <var>a_3</var>
<var>2</var> で割る. すると数列は
<var>{135, 9, 3}</var> となる.
</li>
</ul>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 2</h3>
<pre>
4
631 577 243 199
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 2</h3>
<pre>
0
</pre
>
<p>
全ての要素が奇数なので, 操作はできない. よって答えは
<var>0</var> である.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>入力例 3</h3>
<pre>
10
2184 2126 1721 1800 1024 2528 3360 1945 1280 1776
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>出力例 3</h3>
<pre>
39
</pre
>
</section>
</div>
</span>
<span class="lang-en">
<p>Score: <var>300</var> points</p>
<div class="part">
<section>
<h3>Problem Statement</h3>
<p>
As AtCoder Beginner Contest 100 is taking place, the
office of AtCoder, Inc. is decorated with a sequence of
length <var>N</var>, <var>a = </var>{<var
>a_1, a_2, a_3, ..., a_N</var
>}.<br />
Snuke, an employee, would like to play with this
sequence.
</p>
<p>
Specifically, he would like to repeat the following
operation as many times as possible:
</p>
<pre>For every <var>i</var> satisfying <var>1 \leq i \leq N</var>, perform one of the following: &quot;divide <var>a_i</var> by <var>2</var>&quot; and &quot;multiply <var>a_i</var> by <var>3</var>&quot;.
Here, choosing &quot;multiply <var>a_i</var> by <var>3</var>&quot; for every <var>i</var> is not allowed, and the value of <var>a_i</var> after the operation must be an integer.
</pre>
<p>At most how many operations can be performed?</p>
</section>
</div>
<div class="part">
<section>
<h3>Constraints</h3>
<ul>
<li>
<var>N</var> is an integer between <var>1</var> and
<var>10 \ 000</var> (inclusive).
</li>
<li>
<var>a_i</var> is an integer between <var>1</var> and
<var>1 \ 000 \ 000 \ 000</var> (inclusive).
</li>
</ul>
</section>
</div>
<hr />
<div class="io-style">
<div class="part">
<section>
<h3>Input</h3>
<p>
Input is given from Standard Input in the following
format:
</p>
<pre><var>N</var>
<var>a_1</var> <var>a_2</var> <var>a_3</var> <var>...</var> <var>a_N</var>
</pre>
</section>
</div>
<div class="part">
<section>
<h3>Output</h3>
<p>
Print the maximum number of operations that Snuke can
perform.
</p>
</section>
</div>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 1</h3>
<pre>
3
5 2 4
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 1</h3>
<pre>
3
</pre
>
<p>
The sequence is initially <var>{5, 2, 4}</var>. Three
operations can be performed as follows:
</p>
<ul>
<li>
First, multiply <var>a_1</var> by <var>3</var>,
multiply <var>a_2</var> by <var>3</var> and divide
<var>a_3</var> by <var>2</var>. The sequence is now
<var>{15, 6, 2}</var>.
</li>
<li>
Next, multiply <var>a_1</var> by <var>3</var>, divide
<var>a_2</var> by <var>2</var> and multiply
<var>a_3</var> by <var>3</var>. The sequence is now
<var>{45, 3, 6}</var>.
</li>
<li>
Finally, multiply <var>a_1</var> by <var>3</var>,
multiply <var>a_2</var> by <var>3</var> and divide
<var>a_3</var> by <var>2</var>. The sequence is now
<var>{135, 9, 3}</var>.
</li>
</ul>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 2</h3>
<pre>
4
631 577 243 199
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 2</h3>
<pre>
0
</pre
>
<p>
No operation can be performed since all the elements are
odd. Thus, the answer is <var>0</var>.
</p>
</section>
</div>
<hr />
<div class="part">
<section>
<h3>Sample Input 3</h3>
<pre>
10
2184 2126 1721 1800 1024 2528 3360 1945 1280 1776
</pre
>
</section>
</div>
<div class="part">
<section>
<h3>Sample Output 3</h3>
<pre>
39
</pre
>
</section>
</div>
</span>
</span>
</div>
</div>
</div>
<hr />
<div
class="a2a_kit a2a_kit_size_20 a2a_default_style pull-right"
data-a2a-url="https://atcoder.jp/contests/abc100/tasks/abc100_c?lang=en"
data-a2a-title="C - *3 or /2"
>
<a class="a2a_button_facebook"></a>
<a class="a2a_button_twitter"></a>
<a class="a2a_button_telegram"></a>
<a class="a2a_dd" href="https://www.addtoany.com/share"></a>
</div>
<script async src="//static.addtoany.com/menu/page.js"></script>
</div>
<hr />
</div>
<div class="container" style="margin-bottom: 80px">
<footer class="footer">
<ul>
<li><a href="/contests/abc100/rules">Rule</a></li>
<li><a href="/contests/abc100/glossary">Glossary</a></li>
</ul>
<ul>
<li><a href="/tos">Terms of service</a></li>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/personal">Information Protection Policy</a></li>
<li><a href="/company">Company</a></li>
<li><a href="/faq">FAQ</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
<div class="text-center">
<small id="copyright"
>Copyright Since 2012 &copy;<a href="http://atcoder.co.jp"
>AtCoder Inc.</a
>
All rights reserved.</small
>
</div>
</footer>
</div>
<p id="fixed-server-timer" class="contest-timer"></p>
<div id="scroll-page-top" style="display: none">
<span class="glyphicon glyphicon-arrow-up" aria-hidden="true"></span> Page
Top
</div>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
{
"status": "success",
"user": { "username": null },
"code": "START209",
"isRatedContest": "1",
"isParentContestRated": "0",
"name": "Starters 209 (Rated till 5 star)",
"problems": [],
"banner": "https:\/\/cdn.codechef.com\/download\/small-banner\/START209\/1760933061.png",
"rules": "<h4>CodeChef: A Platform for Aspiring Programmers<\/h4>\n<p class=\"last\">CodeChef was created as a platform to help programmers make it big in the world of algorithms, computer programming, and programming contests. At CodeChef, our dedicated efforts are aimed at reviving the inner geek within you, as we proudly host a thrilling programming (coding) contest every Wednesday.<\/p>\n<h4>About CodeChef Starters:<\/h4>\n<p>CodeChef Starters is a short programming contest which takes place on every Wednesday\u00a0<\/p>\n<h4>Contest Details:<\/h4>\n<ul class=\"last\">\n<li><strong>D<\/strong><strong>uration: <\/strong>\u00a02.00 hours\u00a0<\/li>\n<li><strong>Start Date: <\/strong>Wednesday, 22nd October , 2025 at 20:00 HRS (IST)<\/li>\n<li><strong>End Date: <\/strong>Wednesday, 22nd October, 2025 at 22:00 HRS (IST)<\/li>\n<li>Check your timezone <a href=\"https:\/\/www.timeanddate.com\/worldclock\/fixedtime.html?msg=CodeChef+Starters+209&amp;iso=20251022T20&amp;p1=44&amp;ah=2\" target=\"_blank\" rel=\"nofollow noreferrer noopener\">here<\/a>.<\/li>\n<\/ul>\n<h4>Eligibility Criteria: Anyone with a knack for programming<\/h4>\n<p class=\"last\">Our contests are open to all programmers across the globe.<\/p>\n<h4>What's in it for you?<\/h4>\n<p>The idea behind these programming contests is that we want you to learn while competing. Also, we believe that it is alright to refer to tutorials, books, and other materials, learn a concept, and then apply the same to solve a problem during a contest. But it is <strong>not alright to copy other people's solutions or seek other people's help to solve a problem. <\/strong>All the participants are expected to abide to <a class=\"button blue\" href=\"..\/codeofconduct\">CodeChef's Code Of Conduct<\/a>.<\/p>\n<h4>Rules and Regulations:<\/h4>\n<ul>\n<li>This is an IOI-style contest. This means that the problems will be partially graded. You will get the score for passing certain test data.<\/li>\n<li>The details of the failed test cases will also be visible on your solution page.<\/li>\n<li>You can submit solutions as many times as you'd like, there are no penalties for incorrect submissions. Only your best correct submission will be considered.<\/li>\n<li>Those who achieve the score first will be placed higher in the ranklist in case of a tie.<\/li>\n<li><strong>We have removed all the Institutions that we could not identify from our database. We request you to update your institutions once again by going to your profile page.<\/strong><\/li>\n<li>You can also send in your queries in an email to <a href=\"mailto:help@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\">help@codechef.com<\/a>, during the contest.<\/li>\n<li>Please do not discuss strategy, suggestions, or tips in the comments during a live contest. Posting questions clarifying the problem statement is ok. If you are unsure, email us at <a href=\"mailto:feedback@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\"> feedback@codechef.com<\/a>.<\/li>\n<li>Discussing CodeChef's problems or any aspect of a problem, on any other platform on the web, on identification, could lead to the disabling of the respective account and banning from the community.<\/li>\n<\/ul>\n<p><strong>Note: You can now \"Code, Compile, and Run\" your codes on our <a href=\"..\/ide\">Online IDE<\/a>.<\/strong><\/p>\n<p>However, if you are using any other online development environment, make sure that other contestants don't have access to your code. As a contestant, you are responsible for making sure others don't access the code that you submit. If you use Ideone, make sure to mark your submission \"private\" (not secret)\".<\/p>",
"time": {
"start": 1761143400,
"end": 1761150600,
"freezing": 0,
"current": 1761370410
},
"ip": "2603:7000:3900:1358:3959:b692:6cf3:cb03",
"announcements": "<p><strong>CodeChef \u00d7 Coding Club League (2025-26)<\/strong><br \/><br \/>Partner with CodeChef to build a strong coding culture on campus!<\/p>\n<p><strong>Benefits for Clubs:<\/strong><\/p>\n<ul>\n<li>Platform access and support for Annual Technical events \/ hackathons<\/li>\n<li>Pro access for winners<\/li>\n<li>Dashboard to track member progress<\/li>\n<li>Discounts on CodeChef Pro for all members<\/li>\n<li>Co-branding &amp; promotion on CodeChef channels<br \/><br \/>\u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0<strong style=\"text-align:center;\"><a class=\"button blue\" href=\"codechef-coding-club\" target=\"_blank\" rel=\"noreferrer noopener\">\u00a0Click Here To Know More<\/a><\/strong><\/li>\n<\/ul>\n<p><strong>\u00a0<\/strong><\/p>",
"problemsstats": {
"attempted": [],
"partially_solved": [],
"solved": [],
"locked": []
},
"todos": [],
"stats": null,
"partial_scores": [],
"isRanklistFrozen": false,
"rank_and_score": { "score": "NA", "rank": "NA" },
"is_a_parent_contest": true,
"is_contest_elements_visible": true,
"is_OTP_required": false,
"is_linked_problems_contest": "0",
"custom_contest_page_title": "",
"custom_contest_page_meta_desc": "",
"contest_introduction": "https:\/\/discuss.codechef.com\/t\/invitation-to-codechef-starters-209-rated-upto-5-stars-22nd-october\/124401",
"contest_editorials": "https:\/\/discuss.codechef.com\/tag\/start209",
"contest_video_editorials": "",
"is_older_rating_based_division_system": false,
"division_generation": 3,
"isAssessmentContest": false,
"penalisedUsersCount": 0,
"ttl": 60,
"child_contests": {
"div_1": {
"div": {
"div_number": "1",
"code": "div_1",
"min_rating": 2000,
"max_rating": 50000,
"name": "Division 1",
"description": "Users with rating above 2000"
},
"division_generation": 3,
"contest_code": "START209A",
"contest_link": "\/START209A"
},
"div_2": {
"div": {
"div_number": "2",
"code": "div_2",
"min_rating": 1600,
"max_rating": 1999,
"name": "Division 2",
"description": "Users with rating between 1600 and 1999"
},
"division_generation": 3,
"contest_code": "START209B",
"contest_link": "\/START209B"
},
"div_3": {
"div": {
"div_number": "3",
"code": "div_3",
"min_rating": 1400,
"max_rating": 1599,
"name": "Division 3",
"description": "Users with rating upto 1599"
},
"division_generation": 3,
"contest_code": "START209C",
"contest_link": "\/START209C"
},
"div_4": {
"div": {
"div_number": "4",
"code": "div_4",
"min_rating": 0,
"max_rating": 1399,
"name": "Division 4",
"description": "Users with rating upto 1399"
},
"division_generation": 3,
"contest_code": "START209D",
"contest_link": "\/START209D"
}
},
"user_rating_div": {
"rating": -1,
"div": {
"code": "all",
"min_rating": 0,
"max_rating": 50000,
"name": "All",
"description": "All the users"
}
},
"user_contest_code": null,
"show_div_based_contest": false,
"is_registration_enabled_contest": false,
"is_flexi_time_contest": false,
"duration": "120",
"is_proctored": false,
"autoRefresh": true,
"visitedContests": []
}

View file

@ -1,202 +0,0 @@
{
"status": "success",
"user": { "username": null },
"code": "START209D",
"isRatedContest": "1",
"isParentContestRated": "1",
"name": "Starters 209 (Rated)",
"problems": {
"P1209": {
"code": "P1209",
"name": "Bitcoin Market",
"type": "3",
"successful_submissions": "25131",
"allow_submission": false,
"accuracy": 85.680000000000007,
"problem_url": "\/problems\/P1209",
"submit_url": "\/problems\/P1209",
"status_url": "\/status\/P1209",
"is_added_to_practice": true,
"total_submissions": "33093",
"category_name": "main",
"is_direct_submittable": false
},
"P2209": {
"code": "P2209",
"name": "Divisible Duel",
"type": "3",
"successful_submissions": "21888",
"allow_submission": false,
"accuracy": 64.159999999999997,
"problem_url": "\/problems\/P2209",
"submit_url": "\/problems\/P2209",
"status_url": "\/status\/P2209",
"is_added_to_practice": true,
"total_submissions": "37437",
"category_name": "main",
"is_direct_submittable": false
},
"P3209": {
"code": "P3209",
"name": "Small GCD Sort",
"type": "3",
"successful_submissions": "13450",
"allow_submission": false,
"accuracy": 76.239999999999995,
"problem_url": "\/problems\/P3209",
"submit_url": "\/problems\/P3209",
"status_url": "\/status\/P3209",
"is_added_to_practice": true,
"total_submissions": "19164",
"category_name": "main",
"is_direct_submittable": false
},
"P4209": {
"code": "P4209",
"name": "Tactical Conversion",
"type": "3",
"successful_submissions": "1567",
"allow_submission": false,
"accuracy": 8.4499999999999993,
"problem_url": "\/problems\/P4209",
"submit_url": "\/problems\/P4209",
"status_url": "\/status\/P4209",
"is_added_to_practice": true,
"total_submissions": "20535",
"category_name": "main",
"is_direct_submittable": false
},
"P5209": {
"code": "P5209",
"name": "Binary Love",
"type": "3",
"successful_submissions": "3271",
"allow_submission": false,
"accuracy": 33.530000000000001,
"problem_url": "\/problems\/P5209",
"submit_url": "\/problems\/P5209",
"status_url": "\/status\/P5209",
"is_added_to_practice": true,
"total_submissions": "11128",
"category_name": "main",
"is_direct_submittable": false
},
"P6209E": {
"code": "P6209E",
"name": "High Score (Easy Version)",
"type": "3",
"successful_submissions": "285",
"allow_submission": false,
"accuracy": 7.2800000000000002,
"problem_url": "\/problems\/P6209E",
"submit_url": "\/problems\/P6209E",
"status_url": "\/status\/P6209E",
"is_added_to_practice": true,
"total_submissions": "4535",
"category_name": "main",
"is_direct_submittable": false
},
"P6209": {
"code": "P6209",
"name": "High Score (Hard Version)",
"type": "3",
"successful_submissions": "34",
"allow_submission": false,
"accuracy": 3.1899999999999999,
"problem_url": "\/problems\/P6209",
"submit_url": "\/problems\/P6209",
"status_url": "\/status\/P6209",
"is_added_to_practice": true,
"total_submissions": "1159",
"category_name": "main",
"is_direct_submittable": false
},
"P7209": {
"code": "P7209",
"name": "Easy Grid Game",
"type": "3",
"successful_submissions": "80",
"allow_submission": false,
"accuracy": 5.1100000000000003,
"problem_url": "\/problems\/P7209",
"submit_url": "\/problems\/P7209",
"status_url": "\/status\/P7209",
"is_added_to_practice": true,
"total_submissions": "1740",
"category_name": "main",
"is_direct_submittable": false
},
"P8209": {
"code": "P8209",
"name": "Counting Is Fun",
"type": "3",
"successful_submissions": "22",
"allow_submission": false,
"accuracy": 1.8200000000000001,
"problem_url": "\/problems\/P8209",
"submit_url": "\/problems\/P8209",
"status_url": "\/status\/P8209",
"is_added_to_practice": true,
"total_submissions": "1261",
"category_name": "main",
"is_direct_submittable": false
}
},
"banner": "https:\/\/cdn.codechef.com\/download\/small-banner\/START209D\/1760933097.png",
"rules": "<h4>CodeChef: A Platform for Aspiring Programmers<\/h4>\n<p class=\"last\">CodeChef was created as a platform to help programmers make it big in the world of algorithms, computer programming, and programming contests. At CodeChef, our dedicated efforts are aimed at reviving the inner geek within you, as we proudly host a thrilling programming (coding) contest every Wednesday.<\/p>\n<h4>About CodeChef Starters:<\/h4>\n<p>CodeChef Starters is a short programming contest which takes place on every Wednesday\u00a0<\/p>\n<h4>Contest Details:<\/h4>\n<ul class=\"last\">\n<li><strong>D<\/strong><strong>uration: <\/strong>\u00a02.00 hours\u00a0<\/li>\n<li><strong>Start Date: <\/strong>Wednesday, 22nd October , 2025 at 20:00 HRS (IST)<\/li>\n<li><strong>End Date: <\/strong>Wednesday, 22nd October, 2025 at 22:00 HRS (IST)<\/li>\n<li>Check your timezone <a href=\"https:\/\/www.timeanddate.com\/worldclock\/fixedtime.html?msg=CodeChef+Starters+209&amp;iso=20251022T20&amp;p1=44&amp;ah=2\" target=\"_blank\" rel=\"nofollow noreferrer noopener\">here<\/a>.<\/li>\n<\/ul>\n<h4>Eligibility Criteria: Anyone with a knack for programming<\/h4>\n<p class=\"last\">Our contests are open to all programmers across the globe.<\/p>\n<h4>What's in it for you?<\/h4>\n<p>The idea behind these programming contests is that we want you to learn while competing. Also, we believe that it is alright to refer to tutorials, books, and other materials, learn a concept, and then apply the same to solve a problem during a contest. But it is <strong>not alright to copy other people's solutions or seek other people's help to solve a problem. <\/strong>All the participants are expected to abide to <a class=\"button blue\" href=\"..\/codeofconduct\">CodeChef's Code Of Conduct<\/a>.<\/p>\n<h4>Rules and Regulations:<\/h4>\n<ul>\n<li>This is an IOI-style contest. This means that the problems will be partially graded. You will get the score for passing certain test data.<\/li>\n<li>The details of the failed test cases will also be visible on your solution page.<\/li>\n<li>You can submit solutions as many times as you'd like, there are no penalties for incorrect submissions. Only your best correct submission will be considered.<\/li>\n<li>Those who achieve the score first will be placed higher in the ranklist in case of a tie.<\/li>\n<li><strong>We have removed all the Institutions that we could not identify from our database. We request you to update your institutions once again by going to your profile page.<\/strong><\/li>\n<li>You can also send in your queries in an email to <a href=\"mailto:help@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\">help@codechef.com<\/a>, during the contest.<\/li>\n<li>Please do not discuss strategy, suggestions, or tips in the comments during a live contest. Posting questions clarifying the problem statement is ok. If you are unsure, email us at <a href=\"mailto:feedback@codechef.com\" target=\"_blank\" rel=\"noreferrer noopener\"> feedback@codechef.com<\/a>.<\/li>\n<li>Discussing CodeChef's problems or any aspect of a problem, on any other platform on the web, on identification, could lead to the disabling of the respective account and banning from the community.<\/li>\n<\/ul>\n<p><strong>Note: You can now \"Code, Compile, and Run\" your codes on our <a href=\"..\/ide\">Online IDE<\/a>.<\/strong><\/p>\n<p>However, if you are using any other online development environment, make sure that other contestants don't have access to your code. As a contestant, you are responsible for making sure others don't access the code that you submit. If you use Ideone, make sure to mark your submission \"private\" (not secret)\".<\/p>",
"time": {
"start": 1761143406,
"end": 1761150606,
"freezing": 0,
"current": 1761365589
},
"ip": "2603:7000:3900:1358:3959:b692:6cf3:cb03",
"announcements": "<p><strong>CodeChef \u00d7 Coding Club League (2025-26)<\/strong><br \/><br \/>Partner with CodeChef to build a strong coding culture on campus!<\/p>\n<p><strong>Benefits for Clubs:<\/strong><\/p>\n<ul>\n<li>Platform access and support for Annual Technical events \/ hackathons<\/li>\n<li>Pro access for winners<\/li>\n<li>Dashboard to track member progress<\/li>\n<li>Discounts on CodeChef Pro for all members<\/li>\n<li>Co-branding &amp; promotion on CodeChef channels<br \/><br \/>\u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0<strong style=\"text-align:center;\"><a class=\"button blue\" href=\"codechef-coding-club\" target=\"_blank\" rel=\"noreferrer noopener\">\u00a0Click Here To Know More<\/a><\/strong><\/li>\n<\/ul>\n<p><strong>\u00a0<\/strong><\/p>\n<p>\u00a0<\/p>",
"problemsstats": {
"attempted": [],
"partially_solved": [],
"solved": [],
"locked": []
},
"todos": [],
"stats": null,
"partial_scores": {
"P7209": [{ "score": "100", "count": "80" }],
"P5209": [{ "score": "100", "count": "3271" }],
"P4209": [{ "score": "100", "count": "1567" }],
"P1209": [{ "score": "100", "count": "25131" }],
"P3209": [{ "score": "100", "count": "13450" }],
"P2209": [{ "score": "100", "count": "21888" }],
"P8209": [{ "score": "100", "count": "22" }],
"P6209": [{ "score": "100", "count": "34" }],
"P6209E": [{ "score": "100", "count": "285" }]
},
"isRanklistFrozen": false,
"rank_and_score": { "score": "NA", "rank": "NA" },
"is_a_parent_contest": false,
"is_contest_elements_visible": true,
"is_OTP_required": false,
"is_linked_problems_contest": "0",
"custom_contest_page_title": "",
"custom_contest_page_meta_desc": "",
"contest_introduction": "https:\/\/discuss.codechef.com\/t\/invitation-to-codechef-starters-209-rated-upto-5-stars-22nd-october\/124401",
"contest_editorials": "https:\/\/discuss.codechef.com\/tag\/start209",
"contest_video_editorials": "",
"is_older_rating_based_division_system": false,
"division_generation": 3,
"isAssessmentContest": false,
"penalisedUsersCount": 0,
"ttl": 60,
"scorable_heading": "Scorable Problems for Division 4",
"scorable_message": "",
"division": "Division 4",
"non_scorable_heading": "Non Scorable Problems for Practice",
"non_scorable_message": "<p>The following problems are <b>NOT part of the contest<\/b>, and will not be counted towards your rankings and ratings. These are problems from the other Division(s), made available for you to practice. Click <a href='\/blogs\/how-does-codechef-rating-system-work'>here<\/a> to know more. They will be considered for plagiarism though.<\/p>",
"is_registration_enabled_contest": false,
"is_flexi_time_contest": false,
"duration": "120",
"is_proctored": false,
"autoRefresh": true,
"visitedContests": [],
"user_live_ratings_update_frequency": 15
}

View file

@ -1,99 +0,0 @@
{
"category_name": "main",
"contest_code": "START209D",
"contest_name": "Starters 209 (Rated)",
"status": "success",
"submit_error": "You need to login to submit.",
"is_verified": false,
"problem_code": "P1209",
"contest_category": "9",
"problem_name": "Bitcoin Market",
"intended_contest_code": "START209",
"body": "This is an example problem statement in markdown, and a mini guide on writing statements. Please make sure to remove everything here before publishing your problem.\n\n- Codechef uses markdown for its problem statements. Markdown syntax can be found [here](https:\/\/github.com\/showdownjs\/showdown\/wiki\/Showdown's-Markdown-syntax). Note the `[text](link)` syntax to insert a hyperlink.\n- Codechef also uses $\\LaTeX$ to render mathematical expressions, and you are advised to make liberal use of it to make your statement look good.\n- Text can be made **bold** or *italicized*.\n- **Do not** use HTML tags (p, ul, li, pre, br, ...) in the statement.\n- To insert an image, first upload it to an online hosting service (for an official contest, ask a Codechef admin to do this for you \u2014 this is important) and then use the following syntax: `![alt text](link-to-image)`.\n- If your problem doesn't contain subtasks, ensure that the Subtasks section below is disabled and **all content is deleted from it**.\n\nIf you face any issues, either contact a Codechef admin directly or send us an email at help@codechef.com.\n\nBelow is an example problem statement that uses some of the above-mentioned features.\n\n---------\n\nChef has a simple undirected graph $G$ with $N$ vertices and $M$ edges. A [subgraph](https:\/\/mathworld.wolfram.com\/Subgraph.html) $H$ of $G$ is called *good* if:\n- $H$ is connected\n- $H$ contains all $N$ vertices of $G$\n- There is a unique path between any two vertices in $H$, using only edges in $H$\n\nCount the number of *good* subgraphs of $G$. Since this number might be large, report it modulo $10^9 + 7$.\n\nIn other news, here's a completely unrelated image:\n\n![](https:\/\/s3.amazonaws.com\/codechef_shared\/download\/Images\/START41\/ss3.png).\n\n\n<aside style='background: #f8f8f8;padding: 10px 15px;'><div>All submissions for this problem are available.<\/div><\/aside>",
"problemComponents": {
"constraints": "- $1 \\leq R \\leq 10$",
"constraintsState": true,
"subtasks": "- **Subtask 1 (10 points):** $1 \\leq M \\leq 10$\n- **Subtask 2 (20 points):** The sum of $N$ across all test cases won't exceed $20$.\n- **Subtask 3 (70 points):** No further constraints.",
"subtasksState": false,
"statement": "Chef has recently started investing in **Bitcoin**. \nHe assigns a **market risk level** $R$ (from $1$ to $10$), where: \n\n- $1$ means the market is *very safe*, \n- $10$ means the market is *very risky*. \n\nChef will **buy Bitcoin** only if the risk level is **$4$ or less**. \n\nGiven the current risk level $R$, determine whether Chef should buy Bitcoin.\n\nPrint **\"YES\"** if Chef should buy, otherwise print **\"NO\"**.",
"inputFormat": "- The first and only line of input contains a single integer $R$ \u2014 the current market risk level.",
"inputFormatState": true,
"outputFormat": "Print `YES` if Chef should buy Bitcoin, Otherwise, print `NO`.\n\nYou may print each character of the string in uppercase or lowercase (for example, the strings `YES`, `yEs`, `yes`, and `yeS` will all be treated as identical).\n",
"outputFormatState": true,
"sampleTestCases": [
{
"id": "1",
"input": "2",
"output": "YES",
"explanation": "The current market risk is $2$. \nSince $2$ is not larger than $4$, the risk is small enough, and Chef will buy Bitcoin.",
"isDeleted": false
},
{
"id": "2",
"input": "4",
"output": "YES",
"explanation": "The current market risk is $4$. \nSince $4$ is not larger than $4$, the risk is small enough, and Chef will buy Bitcoin.",
"isDeleted": false
},
{
"id": "3",
"input": "5",
"output": "NO",
"explanation": "The current market risk is $5$. \nSince $5$ is larger than $4$, the risk is too much, and Chef will **not** buy Bitcoin.",
"isDeleted": false
}
]
},
"gumlet_video_url": "",
"video_editorial_url": "https:\/\/youtu.be\/tjUCV9Ld1Kw?si=minop9943wecj1bh",
"text_editorial_body": "<h1><a name=\"problem-link-1\" class=\"anchor\" href=\"#problem-link-1\"><\/a>PROBLEM LINK:<\/h1>\n<p><a href=\"https:\/\/www.codechef.com\/problems\/P1209\">Practice<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209A\/problems\/P1209\">Contest: Division 1<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209B\/problems\/P1209\">Contest: Division 2<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209C\/problems\/P1209\">Contest: Division 3<\/a><br>\n<a href=\"https:\/\/www.codechef.com\/START209D\/problems\/P1209\">Contest: Division 4<\/a><\/p>\n<p><em><strong>Author:<\/strong><\/em> <a href=\"https:\/\/www.codechef.com\/users\/pols_agyi_pols\">pols_agyi_pols<\/a><br>\n<em><strong>Tester:<\/strong><\/em> <a href=\"https:\/\/www.codechef.com\/users\/kingmessi\">kingmessi<\/a><br>\n<em><strong>Editorialist:<\/strong><\/em> <a href=\"https:\/\/www.codechef.com\/users\/iceknight1093\">iceknight1093<\/a><\/p>\n<h1><a name=\"difficulty-2\" class=\"anchor\" href=\"#difficulty-2\"><\/a>DIFFICULTY:<\/h1>\n<p>Cakewalk<\/p>\n<h1><a name=\"prerequisites-3\" class=\"anchor\" href=\"#prerequisites-3\"><\/a>PREREQUISITES:<\/h1>\n<p>None<\/p>\n<h1><a name=\"problem-4\" class=\"anchor\" href=\"#problem-4\"><\/a>PROBLEM:<\/h1>\n<p>Chef will buy bitcoin if the market risk level is no more than <span class=\"math\">4<\/span>.<br>\nThe current market risk level is <span class=\"math\">R<\/span>.<br>\nWill Chef buy bitcoin?<\/p>\n<h1><a name=\"explanation-5\" class=\"anchor\" href=\"#explanation-5\"><\/a>EXPLANATION:<\/h1>\n<p>The answer is <code>Yes<\/code> if <span class=\"math\">R \\le 4<\/span> and <code>No<\/code> otherwise.<br>\nThis can be checked using an <code>if<\/code> condition.<\/p>\n<h1><a name=\"time-complexity-6\" class=\"anchor\" href=\"#time-complexity-6\"><\/a>TIME COMPLEXITY:<\/h1>\n<p><span class=\"math\">\\mathcal{O}(1)<\/span> per testcase.<\/p>\n<h1><a name=\"code-7\" class=\"anchor\" href=\"#code-7\"><\/a>CODE:<\/h1>\n<details>\n<summary>\nEditorialist's code (PyPy3)<\/summary>\n<pre><code class=\"lang-python\">r = int(input())\nprint('Yes' if r &lt;= 4 else 'No')\n<\/code><\/pre>\n<\/details>",
"text_editorial_is_markdown": 0,
"text_editorial_topic_id": 124410,
"languages_supported": "CPP20, PYTH 3, C, JAVA, PYP3, CS2, NODEJS, GO, TS, PHP, kotlin, rust, R",
"max_timelimit": "1",
"source_sizelimit": "50000",
"problem_author": "archit_adm",
"problem_display_authors": ["archit_adm"],
"problem_display_authors_html_handle": "<div class=\"multiple-usernames-container\"><a href='\/users\/archit_adm'>archit_adm<\/a><\/div>",
"problem_tester": null,
"problem_testers_usernames": ["kingmessi"],
"problem_tester_html_handle": "<div class=\"multiple-usernames-container\"><a href='\/users\/kingmessi'><span \n class='rating' \n style='display: inline-block; \n font-size: 10px; \n background: #D0011B;\n padding: 0 3px; \n line-height: 1.3; \n color: white;\n margin-right: 2px;'>7&#9733;<\/span><span class='m-username--link'>kingmessi<\/span><\/a><\/div>",
"problem_editorialist": "iceknight1093",
"date_added": "20-10-2025",
"ready_for_debug": false,
"problem_stats": {
"accuracy": 85.780000000000001,
"successful_submissions": "25325",
"total_submissions": "33327"
},
"user_tags": ["archit_adm", "cakewalk", "start209"],
"computed_tags": [],
"difficulty_rating": "172",
"best_tag": "",
"editorial_url": "",
"time": {
"view_start_date": 1761143406,
"submit_start_date": 1761143406,
"visible_start_date": 1761150606,
"end_date": 1761150606,
"current": 1761365589,
"practice_submission_allowed": false
},
"user": { "username": null, "access": "default", "isPremiumUser": false },
"bookmark_status": false,
"contest_problem_status": "unattempted",
"problem_status": "unattempted",
"is_direct_submittable": false,
"problemDiscussURL": "https:\/\/discuss.codechef.com\/search?q=P1209",
"is_a_practice_or_college_contest": false,
"votes_data": {
"SolutionVoteData": { "upvote_count": 0, "user_vote": 0 },
"HintsVoteData": { "upvote_count": 0, "user_vote": 0 },
"ProblemStatementVoteData": { "upvote_count": 26, "user_vote": 0 },
"DoubtSupportVoteData": { "upvote_count": 0, "user_vote": 0 }
},
"is_proctored": false,
"is_user_verified_for_proctoring": false,
"visitedContests": [],
"isSupportedByJudge": true
}

View file

@ -1,330 +0,0 @@
{
"status": "success",
"message": "All contests list",
"present_contests": [
{
"contest_code": "DEVWEEKEND21",
"contest_name": "Weekend Dev Challenge 21: Full Stack Projects using MERN",
"contest_start_date": "25 Oct 2025 00:00:00",
"contest_end_date": "27 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-25T00:00:00+05:30",
"contest_end_date_iso": "2025-10-27T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 8
}
],
"future_contests": [
{
"contest_code": "START210",
"contest_name": "Starters 210",
"contest_start_date": "29 Oct 2025 20:00:00",
"contest_end_date": "29 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-29T20:00:00+05:30",
"contest_end_date_iso": "2025-10-29T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 0
},
{
"contest_code": "START211",
"contest_name": "Starters 211",
"contest_start_date": "05 Nov 2025 20:00:00",
"contest_end_date": "05 Nov 2025 22:00:00",
"contest_start_date_iso": "2025-11-05T20:00:00+05:30",
"contest_end_date_iso": "2025-11-05T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 0
}
],
"practice_contests": [],
"past_contests": [
{
"contest_code": "START209",
"contest_name": "Starters 209 (Rated till 5 star)",
"contest_start_date": "22 Oct 2025 20:00:00",
"contest_end_date": "22 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-22T20:00:00+05:30",
"contest_end_date_iso": "2025-10-22T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 30408
},
{
"contest_code": "DSAMONDAY08",
"contest_name": "Monday Munch - DSA Challenge 08",
"contest_start_date": "20 Oct 2025 18:00:31",
"contest_end_date": "20 Oct 2025 21:00:31",
"contest_start_date_iso": "2025-10-20T18:00:31+05:30",
"contest_end_date_iso": "2025-10-20T21:00:31+05:30",
"contest_duration": "180",
"distinct_users": 653
},
{
"contest_code": "DEVWEEKEND20",
"contest_name": "Weekend Dev Challenge 20: Full Stack Projects using MERN",
"contest_start_date": "18 Oct 2025 00:00:00",
"contest_end_date": "20 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-18T00:00:00+05:30",
"contest_end_date_iso": "2025-10-20T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 318
},
{
"contest_code": "START208",
"contest_name": "Starters 208 (Rated till 6 star)",
"contest_start_date": "15 Oct 2025 20:00:00",
"contest_end_date": "15 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-15T20:00:00+05:30",
"contest_end_date_iso": "2025-10-15T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 37727
},
{
"contest_code": "DSAMONDAY07",
"contest_name": "Monday Munch - DSA Challenge 07",
"contest_start_date": "13 Oct 2025 18:00:00",
"contest_end_date": "13 Oct 2025 21:00:00",
"contest_start_date_iso": "2025-10-13T18:00:00+05:30",
"contest_end_date_iso": "2025-10-13T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 4934
},
{
"contest_code": "DEVWEEKEND19",
"contest_name": "Weekend Dev Challenge 19: Full Stack Projects using MERN",
"contest_start_date": "11 Oct 2025 00:00:00",
"contest_end_date": "13 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-11T00:00:00+05:30",
"contest_end_date_iso": "2025-10-13T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 5376
},
{
"contest_code": "START207",
"contest_name": "Starters 207 (Rated till 5 star)",
"contest_start_date": "08 Oct 2025 20:00:00",
"contest_end_date": "08 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-08T20:00:00+05:30",
"contest_end_date_iso": "2025-10-08T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 32785
},
{
"contest_code": "DSAMONDAY06",
"contest_name": "Monday Munch - DSA Challenge 06",
"contest_start_date": "06 Oct 2025 18:00:02",
"contest_end_date": "06 Oct 2025 21:00:02",
"contest_start_date_iso": "2025-10-06T18:00:02+05:30",
"contest_end_date_iso": "2025-10-06T21:00:02+05:30",
"contest_duration": "180",
"distinct_users": 892
},
{
"contest_code": "DEVWEEKEND18",
"contest_name": "Weekend Dev Challenge 18: Full Stack Projects using MERN",
"contest_start_date": "04 Oct 2025 00:00:00",
"contest_end_date": "06 Oct 2025 00:00:00",
"contest_start_date_iso": "2025-10-04T00:00:00+05:30",
"contest_end_date_iso": "2025-10-06T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 223
},
{
"contest_code": "START206",
"contest_name": "Starters 206 (Rated till 5 star)",
"contest_start_date": "01 Oct 2025 20:00:00",
"contest_end_date": "01 Oct 2025 22:00:00",
"contest_start_date_iso": "2025-10-01T20:00:00+05:30",
"contest_end_date_iso": "2025-10-01T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 23977
},
{
"contest_code": "DSAMONDAY05",
"contest_name": "Monday Munch - DSA Challenge 05",
"contest_start_date": "29 Sep 2025 18:00:00",
"contest_end_date": "29 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-29T18:00:00+05:30",
"contest_end_date_iso": "2025-09-29T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 1160
},
{
"contest_code": "DEVWEEKEND17",
"contest_name": "Weekend Dev Challenge 17: GenAI Projects using LLM",
"contest_start_date": "27 Sep 2025 00:00:00",
"contest_end_date": "29 Sep 2025 00:00:00",
"contest_start_date_iso": "2025-09-27T00:00:00+05:30",
"contest_end_date_iso": "2025-09-29T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 130
},
{
"contest_code": "START205",
"contest_name": "Starters 205 (Rated till 6 star)",
"contest_start_date": "24 Sep 2025 20:00:00",
"contest_end_date": "24 Sep 2025 22:00:00",
"contest_start_date_iso": "2025-09-24T20:00:00+05:30",
"contest_end_date_iso": "2025-09-24T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 32552
},
{
"contest_code": "DSAMONDAY04",
"contest_name": "Monday Munch - DSA Challenge 04",
"contest_start_date": "22 Sep 2025 18:00:00",
"contest_end_date": "22 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-22T18:00:00+05:30",
"contest_end_date_iso": "2025-09-22T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 759
},
{
"contest_code": "DEVWEEKEND16",
"contest_name": "Weekend Dev Challenge 16: GenAI Projects using LLM",
"contest_start_date": "20 Sep 2025 00:00:00",
"contest_end_date": "22 Sep 2025 00:00:00",
"contest_start_date_iso": "2025-09-20T00:00:00+05:30",
"contest_end_date_iso": "2025-09-22T00:00:00+05:30",
"contest_duration": "2880",
"distinct_users": 171
},
{
"contest_code": "START204",
"contest_name": "Starters 204 (Rated till 5 star)",
"contest_start_date": "17 Sep 2025 20:00:00",
"contest_end_date": "17 Sep 2025 22:00:00",
"contest_start_date_iso": "2025-09-17T20:00:00+05:30",
"contest_end_date_iso": "2025-09-17T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 36282
},
{
"contest_code": "DSAMONDAY03",
"contest_name": "Monday Munch - DSA Challenge 03",
"contest_start_date": "15 Sep 2025 18:00:00",
"contest_end_date": "15 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-15T18:00:00+05:30",
"contest_end_date_iso": "2025-09-15T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 657
},
{
"contest_code": "DEVWEEKEND15",
"contest_name": "Weekend Dev Challenge 15: Classify images using Deep Learning",
"contest_start_date": "13 Sep 2025 00:00:00",
"contest_end_date": "14 Sep 2025 00:00:00",
"contest_start_date_iso": "2025-09-13T00:00:00+05:30",
"contest_end_date_iso": "2025-09-14T00:00:00+05:30",
"contest_duration": "1440",
"distinct_users": 112
},
{
"contest_code": "START203",
"contest_name": "Starters 203 (Rated till 5 star)",
"contest_start_date": "10 Sep 2025 20:00:00",
"contest_end_date": "10 Sep 2025 22:00:00",
"contest_start_date_iso": "2025-09-10T20:00:00+05:30",
"contest_end_date_iso": "2025-09-10T22:00:00+05:30",
"contest_duration": "120",
"distinct_users": 36512
},
{
"contest_code": "DSAMONDAY02",
"contest_name": "Monday Munch - DSA Challenge 02",
"contest_start_date": "08 Sep 2025 18:00:00",
"contest_end_date": "08 Sep 2025 21:00:00",
"contest_start_date_iso": "2025-09-08T18:00:00+05:30",
"contest_end_date_iso": "2025-09-08T21:00:00+05:30",
"contest_duration": "180",
"distinct_users": 737
}
],
"skill_tests": [
{
"contest_code": "basic-python",
"contest_name": "Python Online Test & Quiz",
"contest_start_date": "27 Mar 2024 15:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-27T15:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 61244
},
{
"contest_code": "basic-java",
"contest_name": "Java Online Test & Quiz",
"contest_start_date": "28 Mar 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-28T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 49993
},
{
"contest_code": "basic-c-language",
"contest_name": "C language online test",
"contest_start_date": "28 Mar 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-28T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 41373
},
{
"contest_code": "basic-c-plus-plus",
"contest_name": "C++ Online Test and Quiz",
"contest_start_date": "28 Mar 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:30:00",
"contest_start_date_iso": "2024-03-28T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:30:00+05:30",
"contest_duration": "90",
"problem_count": 30,
"distinct_users": 32507
},
{
"contest_code": "basic-sql",
"contest_name": "SQL Online Test and Quiz",
"contest_start_date": "01 Jun 2024 00:00:00",
"contest_end_date": "01 Jan 2027 01:00:00",
"contest_start_date_iso": "2024-06-01T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T01:00:00+05:30",
"contest_duration": "60",
"problem_count": 17,
"distinct_users": 17426
},
{
"contest_code": "operating-systems",
"contest_name": "Operating Systems Skill Test",
"contest_start_date": "01 Jun 2024 00:00:00",
"contest_end_date": "01 Jan 2027 00:45:00",
"contest_start_date_iso": "2024-06-01T00:00:00+05:30",
"contest_end_date_iso": "2027-01-01T00:45:00+05:30",
"contest_duration": "45",
"problem_count": 30,
"distinct_users": 8751
},
{
"contest_code": "c-language-dsa",
"contest_name": "Data structures and Algorithms in C test",
"contest_start_date": "01 Apr 2024 12:00:00",
"contest_end_date": "01 Jan 2027 02:00:00",
"contest_start_date_iso": "2024-04-01T12:00:00+05:30",
"contest_end_date_iso": "2027-01-01T02:00:00+05:30",
"contest_duration": "120",
"problem_count": 28,
"distinct_users": 6611
}
],
"banners": [
{
"image": "1760933050.png",
"link": "https:\/\/www.codechef.com\/START209"
},
{
"image": "1719492535.png",
"link": "https:\/\/www.codechef.com\/roadmap\/data-structures-and-algorithms"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,156 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet " type="text/css" href="/cses.css?0" id="styles" />
<link
rel="stylesheet alternate"
type="text/css"
href="/cses-dark.css?0"
id="styles-dark"
/>
<meta name="theme-color" content="white" id="theme-color" />
<script type="application/json" id="darkmode-enabled">
false
</script>
<script src="/ui.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/lib/fontawesome/css/all.min.css"
/>
</head>
<body class="with-sidebar">
<div class="header">
<div>
<a href="/" class="logo"><img src="/logo.png?1" alt="CSES" /></a>
<a
class="menu-toggle"
onclick="document.body.classList.toggle('menu-open')"
>
<i class="fas fa-bars"></i>
</a>
<div class="controls">
<a class="account" href="/login">Login</a>
<span>&mdash;</span>
<a
href="/darkmode"
title="Toggle dark mode"
onclick="return toggle_theme()"
><i aria-label="Dark mode" class="fas fa-adjust"></i
><span>Dark mode</span></a
>
</div>
</div>
</div>
<div class="skeleton">
<div class="navigation">
<div class="title-block">
<h3><a href="/problemset/list/">CSES Problem Set</a></h3>
<h1>Weird Algorithm</h1>
<ul class="nav">
<li><a href="/problemset/task/1068/" class="current">Task</a></li>
<li><a href="/problemset/stats/1068/">Statistics</a></li>
</ul>
</div>
</div>
<div class="content-wrapper">
<div class="content">
<title>CSES - Weird Algorithm</title
><link rel="stylesheet" href="/lib/katex/katex.min.css" />
<script defer src="/lib/katex/katex.min.js"></script>
<script defer src="/lib/katex/contrib/copy-tex.min.js"></script>
<script
defer
src="/lib/google-code-prettify/run_prettify.js"
></script>
<script>
addEventListener('DOMContentLoaded', function (e) {
const mathElements = document.getElementsByClassName('math')
const macros = {}
for (let element of mathElements) {
katex.render(element.textContent, element, {
displayMode: element.classList.contains('math-display'),
throwOnError: false,
globalGroup: true,
macros
})
}
})
</script>
<ul class="task-constraints">
<li><b>Time limit:</b> 1.00 s</li>
<li><b>Memory limit:</b> 512 MB</li>
</ul>
<div class="md">
<p>
Consider an algorithm that takes as input a positive integer
<span class="math math-inline">n</span>. If
<span class="math math-inline">n</span> is even, the algorithm
divides it by two, and if
<span class="math math-inline">n</span> is odd, the algorithm
multiplies it by three and adds one. The algorithm repeats this,
until <span class="math math-inline">n</span> is one. For example,
the sequence for <span class="math math-inline">n=3</span> is as
follows:
<span class="math math-display">
3 \rightarrow 10 \rightarrow 5 \rightarrow 16 \rightarrow 8
\rightarrow 4 \rightarrow 2 \rightarrow 1</span
>
Your task is to simulate the execution of the algorithm for a
given value of <span class="math math-inline">n</span>.
</p>
<h1 id="input">Input</h1>
<p>
The only input line contains an integer
<span class="math math-inline">n</span>.
</p>
<h1 id="output">Output</h1>
<p>
Print a line that contains all values of
<span class="math math-inline">n</span> during the algorithm.
</p>
<h1 id="constraints">Constraints</h1>
<ul>
<li><span class="math math-inline">1 \le n \le 10^6</span></li>
</ul>
<h1 id="example">Example</h1>
<p>Input:</p>
<pre>
3
</pre
>
<p>Output:</p>
<pre>
3 10 5 16 8 4 2 1
</pre
>
</div>
</div>
<div class="nav sidebar">
<h4>Introductory Problems</h4>
<a class="current" href="/problemset/task/1068"
>Weird Algorithm<span class="task-score icon"></span></a
><a href="/problemset/task/1083"
>Missing Number<span class="task-score icon"></span></a
><a href="/problemset/task/1069"
>Repetitions<span class="task-score icon"></span></a
><a href="/problemset/task/1094"
>Increasing Array<span class="task-score icon"></span></a
><a href="/problemset/task/1070"
>Permutations<span class="task-score icon"></span></a
><a href="/problemset/task/1071"
>Number Spiral<span class="task-score icon"></span></a
><a href="/problemset/task/1072"
>Two Knights<span class="task-score icon"></span></a
><a href="/problemset/task/1092"
>Two Sets<span class="task-score icon"></span></a
>...
<hr />
</div>
</div>
</div>
</body>
</html>

View file

@ -1,150 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="stylesheet " type="text/css" href="/cses.css?0" id="styles" />
<link
rel="stylesheet alternate"
type="text/css"
href="/cses-dark.css?0"
id="styles-dark"
/>
<meta name="theme-color" content="white" id="theme-color" />
<script type="application/json" id="darkmode-enabled">
false
</script>
<script src="/ui.js"></script>
<link
rel="stylesheet"
type="text/css"
href="/lib/fontawesome/css/all.min.css"
/>
</head>
<body class="with-sidebar">
<div class="header">
<div>
<a href="/" class="logo"><img src="/logo.png?1" alt="CSES" /></a>
<a
class="menu-toggle"
onclick="document.body.classList.toggle('menu-open')"
>
<i class="fas fa-bars"></i>
</a>
<div class="controls">
<a class="account" href="/login">Login</a>
<span>&mdash;</span>
<a
href="/darkmode"
title="Toggle dark mode"
onclick="return toggle_theme()"
><i aria-label="Dark mode" class="fas fa-adjust"></i
><span>Dark mode</span></a
>
</div>
</div>
</div>
<div class="skeleton">
<div class="navigation">
<div class="title-block">
<h3><a href="/problemset/list/">CSES Problem Set</a></h3>
<h1>Distinct Numbers</h1>
<ul class="nav">
<li><a href="/problemset/task/1621/" class="current">Task</a></li>
<li><a href="/problemset/stats/1621/">Statistics</a></li>
</ul>
</div>
</div>
<div class="content-wrapper">
<div class="content">
<title>CSES - Distinct Numbers</title
><link rel="stylesheet" href="/lib/katex/katex.min.css" />
<script defer src="/lib/katex/katex.min.js"></script>
<script defer src="/lib/katex/contrib/copy-tex.min.js"></script>
<script
defer
src="/lib/google-code-prettify/run_prettify.js"
></script>
<script>
addEventListener('DOMContentLoaded', function (e) {
const mathElements = document.getElementsByClassName('math')
const macros = {}
for (let element of mathElements) {
katex.render(element.textContent, element, {
displayMode: element.classList.contains('math-display'),
throwOnError: false,
globalGroup: true,
macros
})
}
})
</script>
<ul class="task-constraints">
<li><b>Time limit:</b> 1.00 s</li>
<li><b>Memory limit:</b> 512 MB</li>
</ul>
<div class="md">
<p>
You are given a list of
<span class="math math-inline">n</span> integers, and your task is
to calculate the number of <em>distinct</em> values in the list.
</p>
<h1 id="input">Input</h1>
<p>
The first input line has an integer
<span class="math math-inline">n</span>: the number of values.
</p>
<p>
The second line has
<span class="math math-inline">n</span> integers
<span class="math math-inline">x_1,x_2,\dots,x_n</span>.
</p>
<h1 id="output">Output</h1>
<p>Print one integers: the number of distinct values.</p>
<h1 id="constraints">Constraints</h1>
<ul>
<li>
<span class="math math-inline">1 \le n \le 2 \cdot 10^5</span>
</li>
<li><span class="math math-inline">1 \le x_i \le 10^9</span></li>
</ul>
<h1 id="example">Example</h1>
<p>Input:</p>
<pre>
5
2 3 2 2 3
</pre
>
<p>Output:</p>
<pre>
2
</pre
>
</div>
</div>
<div class="nav sidebar">
<h4>Sorting and Searching</h4>
<a class="current" href="/problemset/task/1621"
>Distinct Numbers<span class="task-score icon"></span></a
><a href="/problemset/task/1084"
>Apartments<span class="task-score icon"></span></a
><a href="/problemset/task/1090"
>Ferris Wheel<span class="task-score icon"></span></a
><a href="/problemset/task/1091"
>Concert Tickets<span class="task-score icon"></span></a
><a href="/problemset/task/1619"
>Restaurant Customers<span class="task-score icon"></span></a
><a href="/problemset/task/1629"
>Movie Festival<span class="task-score icon"></span></a
><a href="/problemset/task/1640"
>Sum of Two Values<span class="task-score icon"></span></a
><a href="/problemset/task/1643"
>Maximum Subarray Sum<span class="task-score icon"></span></a
>...
<hr />
</div>
</div>
</div>
</body>
</html>

View file

@ -1,89 +0,0 @@
import pytest
from scrapers.models import (
ContestListResult,
MetadataResult,
TestsResult,
)
MATRIX = {
"cses": {
"metadata": ("introductory_problems",),
"tests": ("introductory_problems",),
"contests": tuple(),
},
"atcoder": {
"metadata": ("abc100",),
"tests": ("abc100",),
"contests": tuple(),
},
"codeforces": {
"metadata": ("1550",),
"tests": ("1550",),
"contests": tuple(),
},
"codechef": {
"metadata": ("START209D",),
"tests": ("START209D",),
"contests": tuple(),
},
}
@pytest.mark.parametrize("scraper", MATRIX.keys())
@pytest.mark.parametrize("mode", ["metadata", "tests", "contests"])
def test_scraper_offline_fixture_matrix(run_scraper_offline, scraper, mode):
args = MATRIX[scraper][mode]
rc, objs = run_scraper_offline(scraper, mode, *args)
assert rc in (0, 1), f"Bad exit code {rc}"
assert objs, f"No JSON output for {scraper}:{mode}"
if mode == "metadata":
model = MetadataResult.model_validate(objs[-1])
assert model.success is True
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
for obj in objs:
if "success" in obj and "tests" in obj and "problem_id" in obj:
tr = TestsResult.model_validate(obj)
assert tr.problem_id != ""
assert isinstance(tr.tests, list)
assert hasattr(tr, "combined"), "Missing combined field"
assert tr.combined is not None, "combined field is None"
assert hasattr(tr.combined, "input"), "combined missing input"
assert hasattr(tr.combined, "expected"), "combined missing expected"
assert isinstance(tr.combined.input, str), "combined.input not string"
assert isinstance(tr.combined.expected, str), (
"combined.expected not string"
)
assert hasattr(tr, "multi_test"), "Missing multi_test field"
assert isinstance(tr.multi_test, bool), "multi_test not boolean"
validated_any = True
else:
assert "problem_id" in obj
assert "tests" in obj and isinstance(obj["tests"], list)
assert (
"timeout_ms" in obj and "memory_mb" in obj and "interactive" in obj
)
assert "combined" in obj, "Missing combined field in raw JSON"
assert isinstance(obj["combined"], dict), "combined not a dict"
assert "input" in obj["combined"], "combined missing input key"
assert "expected" in obj["combined"], "combined missing expected key"
assert isinstance(obj["combined"]["input"], str), (
"combined.input not string"
)
assert isinstance(obj["combined"]["expected"], str), (
"combined.expected not string"
)
assert "multi_test" in obj, "Missing multi_test field in raw JSON"
assert isinstance(obj["multi_test"], bool), "multi_test not boolean"
validated_any = True
assert validated_any, "No valid tests payloads validated"

818
uv.lock generated
View file

@ -2,623 +2,111 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.11" requires-python = ">=3.11"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.0"
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" }
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 = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "basedpyright"
version = "1.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/97/45805d2432221d3b86fc9220ecc7f5a72acf9a688b5a80fb0f81ae146133/basedpyright-1.35.0.tar.gz", hash = "sha256:2a7e0bd476623d48499e2b18ff6ed19dc28c51909cf9e1152ad355b5809049ad", size = 22814712, upload-time = "2025-12-03T14:17:13.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/b0/5d33b280b787bd972895e7c42f08dfd8cd960e680f386e3a950ccce411ad/basedpyright-1.35.0-py3-none-any.whl", hash = "sha256:4f4f84023df5a0cd4ee154916ba698596682ac98bacfa22c941ed6aaf07bba4e", size = 11872872, upload-time = "2025-12-03T14:17:09.749Z" },
]
[[package]] [[package]]
name = "beautifulsoup4" name = "beautifulsoup4"
version = "4.14.3" version = "4.13.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "soupsieve" }, { name = "soupsieve" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } sdist = { url = "https://files.pythonhosted.org/packages/85/2e/3e5079847e653b1f6dc647aa24549d68c6addb4c595cc0d902d1b19308ad/beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695", size = 622954, upload-time = "2025-08-24T14:06:13.168Z" }
wheels = [ 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" }, { url = "https://files.pythonhosted.org/packages/04/eb/f4151e0c7377a6e08a38108609ba5cede57986802757848688aeedd1b9e8/beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a", size = 105113, upload-time = "2025-08-24T14:06:14.884Z" },
] ]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.11.12" version = "2025.8.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
] ]
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.4" version = "3.4.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" },
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" },
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" },
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" },
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" },
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ 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]] [[package]]
name = "colorama" name = "cloudscraper"
version = "0.4.6" version = "1.2.71"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
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 = "curl-cffi"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "pyparsing" },
{ name = "cffi" }, { name = "requests" },
{ name = "requests-toolbelt" },
] ]
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/ac/25/6d0481860583f44953bd791de0b7c4f6d7ead7223f8a17e776247b34a5b4/cloudscraper-1.2.71.tar.gz", hash = "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3", size = 93261, upload-time = "2023-04-25T23:20:19.467Z" }
wheels = [ 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/81/97/fc88803a451029688dffd7eb446dc1b529657577aec13aceff1cc9628c5d/cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0", size = 99652, upload-time = "2023-04-25T23:20:15.974Z" },
{ 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" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
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 = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "identify"
version = "2.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
] ]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.10"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
] ]
[[package]] [[package]]
name = "iniconfig" name = "pyparsing"
version = "2.3.0" version = "3.2.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" }
wheels = [ 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" }, { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" },
]
[[package]]
name = "ndjson"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/d5/209b6ca94566f9c94c0ec41cee1681c0a3b92a306a84a9b0fcd662088dc3/ndjson-0.3.1.tar.gz", hash = "sha256:bf9746cb6bb1cb53d172cda7f154c07c786d665ff28341e4e689b796b229e5d6", size = 6448, upload-time = "2020-02-25T05:01:07.873Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/c9/04ba0056011ba96a58163ebfd666d8385300bd12da1afe661a5a147758d7/ndjson-0.3.1-py2.py3-none-any.whl", hash = "sha256:839c22275e6baa3040077b83c005ac24199b94973309a8a1809be962c753a410", size = 5305, upload-time = "2020-02-25T05:01:06.39Z" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/89/da307731fdbb05a5f640b26de5b8ac0dc463fef059162accfc89e32f73bc/nodejs_wheel_binaries-24.11.1.tar.gz", hash = "sha256:413dfffeadfb91edb4d8256545dea797c237bba9b3faefea973cde92d96bb922", size = 8059, upload-time = "2025-11-18T18:21:58.207Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/5f/be5a4112e678143d4c15264d918f9a2dc086905c6426eb44515cf391a958/nodejs_wheel_binaries-24.11.1-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:0e14874c3579def458245cdbc3239e37610702b0aa0975c1dc55e2cb80e42102", size = 55114309, upload-time = "2025-11-18T18:21:21.697Z" },
{ url = "https://files.pythonhosted.org/packages/fa/1c/2e9d6af2ea32b65928c42b3e5baa7a306870711d93c3536cb25fc090a80d/nodejs_wheel_binaries-24.11.1-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:c2741525c9874b69b3e5a6d6c9179a6fe484ea0c3d5e7b7c01121c8e5d78b7e2", size = 55285957, upload-time = "2025-11-18T18:21:27.177Z" },
{ url = "https://files.pythonhosted.org/packages/d0/79/35696d7ba41b1bd35ef8682f13d46ba38c826c59e58b86b267458eb53d87/nodejs_wheel_binaries-24.11.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5ef598101b0fb1c2bf643abb76dfbf6f76f1686198ed17ae46009049ee83c546", size = 59645875, upload-time = "2025-11-18T18:21:33.004Z" },
{ url = "https://files.pythonhosted.org/packages/b4/98/2a9694adee0af72bc602a046b0632a0c89e26586090c558b1c9199b187cc/nodejs_wheel_binaries-24.11.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cde41d5e4705266688a8d8071debf4f8a6fcea264c61292782672ee75a6905f9", size = 60140941, upload-time = "2025-11-18T18:21:37.228Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d6/573e5e2cba9d934f5f89d0beab00c3315e2e6604eb4df0fcd1d80c5a07a8/nodejs_wheel_binaries-24.11.1-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:78bc5bb889313b565df8969bb7423849a9c7fc218bf735ff0ce176b56b3e96f0", size = 61644243, upload-time = "2025-11-18T18:21:43.325Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e6/643234d5e94067df8ce8d7bba10f3804106668f7a1050aeb10fdd226ead4/nodejs_wheel_binaries-24.11.1-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c79a7e43869ccecab1cae8183778249cceb14ca2de67b5650b223385682c6239", size = 62225657, upload-time = "2025-11-18T18:21:47.708Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/2fb05127102a80225cab7a75c0e9edf88a0a1b79f912e1e36c7c1aaa8f4e/nodejs_wheel_binaries-24.11.1-py2.py3-none-win_amd64.whl", hash = "sha256:10197b1c9c04d79403501766f76508b0dac101ab34371ef8a46fcf51773497d0", size = 41322308, upload-time = "2025-11-18T18:21:51.347Z" },
{ 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 = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
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 = "platformdirs"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
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 = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" }
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 = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ 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 = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
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 = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]] [[package]]
@ -637,29 +125,15 @@ wheels = [
] ]
[[package]] [[package]]
name = "ruff" name = "requests-toolbelt"
version = "0.14.8" version = "1.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" } dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" }, { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
{ url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" },
{ url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" },
{ url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" },
{ url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" },
{ url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" },
{ url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" },
{ url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" },
{ url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" },
{ url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" },
{ url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" },
{ url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" },
{ url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" },
{ url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" },
{ url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" },
] ]
[[package]] [[package]]
@ -667,50 +141,18 @@ name = "scrapers"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "backoff" },
{ name = "beautifulsoup4" }, { name = "beautifulsoup4" },
{ name = "curl-cffi" }, { name = "cloudscraper" },
{ name = "httpx" },
{ name = "ndjson" },
{ name = "pydantic" },
{ name = "requests" }, { name = "requests" },
] ]
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-mock" },
{ name = "ruff" },
{ name = "ty" },
{ name = "types-beautifulsoup4" },
{ name = "types-requests" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "backoff", specifier = ">=2.2.1" },
{ name = "beautifulsoup4", specifier = ">=4.13.5" }, { name = "beautifulsoup4", specifier = ">=4.13.5" },
{ name = "curl-cffi", specifier = ">=0.13.0" }, { name = "cloudscraper", specifier = ">=1.2.71" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "ndjson", specifier = ">=0.3.1" },
{ name = "pydantic", specifier = ">=2.11.10" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
] ]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.31.6" },
{ name = "pre-commit", specifier = ">=4.3.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-mock", specifier = ">=3.12.0" },
{ name = "ruff", specifier = ">=0.14.2" },
{ name = "ty", specifier = ">=0.0.1a32" },
{ name = "types-beautifulsoup4", specifier = ">=4.12.0.20250516" },
{ name = "types-requests", specifier = ">=2.32.4.20250913" },
]
[[package]] [[package]]
name = "soupsieve" name = "soupsieve"
version = "2.8" version = "2.8"
@ -720,116 +162,20 @@ 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" }, { 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 = "ty"
version = "0.0.1a32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/92/8da015685fb83734a2a83de02080e64d182509de77fa9bcf3eed12eeab4b/ty-0.0.1a32.tar.gz", hash = "sha256:12f62e8a3dd0eaeb9557d74b1c32f0616ae40eae10a4f411e1e2a73225f67ff2", size = 4689151, upload-time = "2025-12-05T21:04:26.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e6/fdc35c9ba047f16afdfedf36fb51c221e0190ccde9f70ee28e77084d6612/ty-0.0.1a32-py3-none-linux_armv6l.whl", hash = "sha256:ffe595eaf616f06f58f951766477830a55c2502d2c9f77dde8f60d9a836e0645", size = 9673128, upload-time = "2025-12-05T21:04:17.702Z" },
{ url = "https://files.pythonhosted.org/packages/19/20/eaff31048e2f309f37478f7d715c8de9f9bab03cba4758da27b9311147af/ty-0.0.1a32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:07f1dce88ad6028fb14665aefe4e6697012c34bd48edd37d02b7eb6a833dbf62", size = 9434094, upload-time = "2025-12-05T21:04:03.383Z" },
{ url = "https://files.pythonhosted.org/packages/67/d4/ea8ed57d11b81c459f23561fd6bfb0f54a8d4120cf72541e3bdf71d46202/ty-0.0.1a32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8fab7ed12528c77ddd600a9638ca859156a53c20f1e381353fa87a255bd397eb", size = 8980296, upload-time = "2025-12-05T21:04:28.912Z" },
{ url = "https://files.pythonhosted.org/packages/49/02/3ce98bbfbb3916678d717ee69358d38a404ca9a39391dda8874b66dd5ee7/ty-0.0.1a32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ace395280fc21e25eff0a53cfbd68170f90a4b8ef2f85dfabe1ecbca2ced456b", size = 9263054, upload-time = "2025-12-05T21:04:05.619Z" },
{ url = "https://files.pythonhosted.org/packages/b7/be/a639638bcd1664de2d70a87da6c4fe0e3272a60b7fa3f0c108a956a456bd/ty-0.0.1a32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2bcbeed7f5ed8e3c1c7e525fce541e7b943ac04ee7fe369a926551b5e50ea4a8", size = 9451396, upload-time = "2025-12-05T21:04:01.265Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a4/2bcf54e842a3d10dc14b369f28a3bab530c5d7ddba624e910b212bda93ee/ty-0.0.1a32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60ff2e4493f90f81a260205d87719bb1d3420928a1e4a2a7454af7cbdfed2047", size = 9862726, upload-time = "2025-12-05T21:04:08.806Z" },
{ url = "https://files.pythonhosted.org/packages/5f/c7/19e6719496e59f2f082f34bcac312698366cf50879fdcc3ef76298bfe6a0/ty-0.0.1a32-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:53cad50a59a0d943b06872e0b10f9f2b564805c2ea93f64c7798852bc1901954", size = 10475051, upload-time = "2025-12-05T21:04:31.059Z" },
{ url = "https://files.pythonhosted.org/packages/88/77/bdf0ddb066d2b62f141d058f8a33bb7c8628cdbb8bfa75b20e296b79fb4e/ty-0.0.1a32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:343d43cdc1d7f649ea2baa64ac2b479da3d679239b94509f1df12f7211561ea9", size = 10232712, upload-time = "2025-12-05T21:04:19.849Z" },
{ url = "https://files.pythonhosted.org/packages/ed/07/f73260a461762a581a007015c1019d40658828ce41576f8c1db88dee574d/ty-0.0.1a32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f45483e4a84bcf622413712164ea687ce323a9f7013b9e7977c5d623ed937ca9", size = 10237705, upload-time = "2025-12-05T21:04:35.366Z" },
{ url = "https://files.pythonhosted.org/packages/2c/57/dbb92206cf2f798d8c51ea16504e8afb90a139d0ff105c31cec9a1db29f9/ty-0.0.1a32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d452f30d47002a6bafc36d1b6aee42c321e9ec9f7f43a04a2ee7d48c208b86c", size = 9766469, upload-time = "2025-12-05T21:04:22.236Z" },
{ url = "https://files.pythonhosted.org/packages/c3/5e/143d93bd143abcebcbaa98c8aeec78898553d62d0a5a432cd79e0cf5bd6d/ty-0.0.1a32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:86c4e31737fe954637890cef1f3e1b479ffb20e836cac3b76050bdbe80005010", size = 9238592, upload-time = "2025-12-05T21:04:11.33Z" },
{ url = "https://files.pythonhosted.org/packages/21/b8/225230ae097ed88f3c92ad974dd77f8e4f86f2594d9cd0c729da39769878/ty-0.0.1a32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:daf15fa03bc39a76a0fbc9c2d81d79d528f584e3fbe08d71981e3f7912db91d6", size = 9502161, upload-time = "2025-12-05T21:04:37.642Z" },
{ url = "https://files.pythonhosted.org/packages/85/13/cc89955c9637f25f3aca2dd7749c6008639ef036f0b9bea3e9d89e892ff9/ty-0.0.1a32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6128f6bab5c6dab3d08689fed1d529dc34f50f221f89c8e16064ed0c549dad7a", size = 9603058, upload-time = "2025-12-05T21:04:39.532Z" },
{ url = "https://files.pythonhosted.org/packages/46/77/1fe2793c8065a02d1f70ca7da1b87db49ca621bcbbdb79a18ad79d5d0ab2/ty-0.0.1a32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:55aab688be1b46776a5a458a1993cae0da7725932c45393399c479c2fa979337", size = 9879903, upload-time = "2025-12-05T21:04:13.567Z" },
{ url = "https://files.pythonhosted.org/packages/fc/47/fd58e80a3e42310b4b649340d5d97403fe796146cae8678b3a031a414b8e/ty-0.0.1a32-py3-none-win32.whl", hash = "sha256:f55ec25088a09236ad1578b656a07fa009c3a353f5923486905ba48175d142a6", size = 9077703, upload-time = "2025-12-05T21:04:15.849Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/209c417c69317339ea8e9b3277fd98364a0e97dd1ffd3585e143ec7b4e57/ty-0.0.1a32-py3-none-win_amd64.whl", hash = "sha256:ed8d5cbd4e47dfed86aaa27e243008aa4e82b6a5434f3ab95c26d3ee5874d9d7", size = 9922426, upload-time = "2025-12-05T21:04:33.289Z" },
{ url = "https://files.pythonhosted.org/packages/e0/1c/350fd851fb91244f8c80cec218009cbee7564d76c14e2f423b47e69a5cbc/ty-0.0.1a32-py3-none-win_arm64.whl", hash = "sha256:dbb25f9b513d34cee8ce419514eaef03313f45c3f7ab4eb6e6d427ea1f6854af", size = 9453761, upload-time = "2025-12-05T21:04:24.502Z" },
]
[[package]]
name = "types-beautifulsoup4"
version = "4.12.0.20250516"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-html5lib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/d1/32b410f6d65eda94d3dfb0b3d0ca151f12cb1dc4cef731dcf7cbfd8716ff/types_beautifulsoup4-4.12.0.20250516.tar.gz", hash = "sha256:aa19dd73b33b70d6296adf92da8ab8a0c945c507e6fb7d5db553415cc77b417e", size = 16628, upload-time = "2025-05-16T03:09:09.93Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/79/d84de200a80085b32f12c5820d4fd0addcbe7ba6dce8c1c9d8605e833c8e/types_beautifulsoup4-4.12.0.20250516-py3-none-any.whl", hash = "sha256:5923399d4a1ba9cc8f0096fe334cc732e130269541d66261bb42ab039c0376ee", size = 16879, upload-time = "2025-05-16T03:09:09.051Z" },
]
[[package]]
name = "types-html5lib"
version = "1.1.11.20251117"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/f3/d9a1bbba7b42b5558a3f9fe017d967f5338cf8108d35991d9b15fdea3e0d/types_html5lib-1.1.11.20251117.tar.gz", hash = "sha256:1a6a3ac5394aa12bf547fae5d5eff91dceec46b6d07c4367d9b39a37f42f201a", size = 18100, upload-time = "2025-11-17T03:08:00.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/ab/f5606db367c1f57f7400d3cb3bead6665ee2509621439af1b29c35ef6f9e/types_html5lib-1.1.11.20251117-py3-none-any.whl", hash = "sha256:2a3fc935de788a4d2659f4535002a421e05bea5e172b649d33232e99d4272d08", size = 24302, upload-time = "2025-11-17T03:07:59.996Z" },
]
[[package]]
name = "types-requests"
version = "2.32.4.20250913"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" },
]
[[package]]
name = "types-webencodings"
version = "0.5.0.20251108"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/66/d6/75e381959a2706644f02f7527d264de3216cf6ed333f98eff95954d78e07/types_webencodings-0.5.0.20251108.tar.gz", hash = "sha256:2378e2ceccced3d41bb5e21387586e7b5305e11519fc6b0659c629f23b2e5de4", size = 7470, upload-time = "2025-11-08T02:56:00.132Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/4e/8fcf33e193ce4af03c19d0e08483cf5f0838e883f800909c6bc61cb361be/types_webencodings-0.5.0.20251108-py3-none-any.whl", hash = "sha256:e21f81ff750795faffddaffd70a3d8bfff77d006f22c27e393eb7812586249d8", size = 8715, upload-time = "2025-11-08T02:55:59.456Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.14.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
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]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.0" version = "2.5.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/43/554c2569b62f49350597348fc3ac70f786e3c32e7f19d266e19817812dd3/urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1", size = 432585, upload-time = "2025-12-05T15:08:47.885Z" } sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/1a/9ffe814d317c5224166b23e7c47f606d6e473712a2fad0f704ea9b99f246/urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f", size = 131083, upload-time = "2025-12-05T15:08:45.983Z" }, { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "virtualenv"
version = "20.35.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
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" },
] ]

View file

@ -16,15 +16,3 @@ any = true
[it] [it]
any = true any = true
[before_each]
any = true
[after_each]
any = true
[spy]
any = true
[stub]
any = true