try to fix the setup

This commit is contained in:
Barrett Ruth 2026-02-18 13:33:49 -05:00 committed by Barrett Ruth
parent b36ffba63a
commit 1162e7046b
11 changed files with 256 additions and 1359 deletions

18
flake.lock generated
View file

@ -18,7 +18,23 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1689347949,
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
"owner": "nix-systems",
"repo": "default-linux",
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default-linux",
"type": "github"
} }
} }
}, },

View file

@ -1,12 +1,72 @@
{ {
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }: { systems.url = "github:nix-systems/default-linux";
devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell {
packages = with nixpkgs.legacyPackages.x86_64-linux; [
uv
python312
];
};
}; };
outputs =
{
self,
nixpkgs,
systems,
}:
let
eachSystem = nixpkgs.lib.genAttrs (import systems);
pkgsFor = system: nixpkgs.legacyPackages.${system};
mkPythonEnv =
pkgs:
pkgs.python312.withPackages (ps: [
ps.backoff
ps.beautifulsoup4
ps.curl-cffi
ps.httpx
ps.ndjson
ps.pydantic
ps.requests
]);
mkPlugin =
pkgs:
let
pythonEnv = mkPythonEnv pkgs;
in
pkgs.vimUtils.buildVimPlugin {
pname = "cp-nvim";
version = "0-unstable-${self.shortRev or self.dirtyShortRev or "dev"}";
src = self;
postPatch = ''
substituteInPlace lua/cp/utils.lua \
--replace-fail "local _nix_python = nil" \
"local _nix_python = '${pythonEnv.interpreter}'"
'';
nvimSkipModule = [
"cp.pickers.telescope"
"cp.version"
];
passthru = { inherit pythonEnv; };
meta.description = "Competitive programming plugin for Neovim";
};
in
{
overlays.default = final: prev: {
vimPlugins = prev.vimPlugins // {
cp-nvim = mkPlugin final;
};
};
packages = eachSystem (system: {
default = mkPlugin (pkgsFor system);
pythonEnv = mkPythonEnv (pkgsFor system);
});
devShells = eachSystem (system: {
default = (pkgsFor system).mkShell {
packages = with (pkgsFor system); [
uv
python312
];
};
});
};
} }

View file

@ -5,6 +5,8 @@ local utils = require('cp.utils')
local function check() local function check()
vim.health.start('cp.nvim [required] ~') vim.health.start('cp.nvim [required] ~')
utils.setup_python_env()
if vim.fn.has('nvim-0.10.0') == 1 then if vim.fn.has('nvim-0.10.0') == 1 then
vim.health.ok('Neovim 0.10.0+ detected') vim.health.ok('Neovim 0.10.0+ detected')
else else
@ -16,22 +18,31 @@ local function check()
vim.health.error('Windows is not supported') vim.health.error('Windows is not supported')
end end
if vim.fn.executable('uv') == 1 then if utils.is_nix_build() then
vim.health.ok('uv executable found') vim.health.ok('Nix-built Python environment detected')
local r = vim.system({ 'uv', '--version' }, { text = true }):wait() local py = utils.get_nix_python()
local r = vim.system({ py, '--version' }, { text = true }):wait()
if r.code == 0 then if r.code == 0 then
vim.health.info('uv version: ' .. r.stdout:gsub('\n', '')) vim.health.info('Python: ' .. r.stdout:gsub('\n', ''))
end end
else else
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)') if vim.fn.executable('uv') == 1 then
end vim.health.ok('uv executable found')
local r = vim.system({ 'uv', '--version' }, { text = true }):wait()
if r.code == 0 then
vim.health.info('uv version: ' .. r.stdout:gsub('\n', ''))
end
else
vim.health.warn('uv not found (install https://docs.astral.sh/uv/ for scraping)')
end
local plugin_path = utils.get_plugin_path() local plugin_path = utils.get_plugin_path()
local venv_dir = plugin_path .. '/.venv' local venv_dir = plugin_path .. '/.venv'
if vim.fn.isdirectory(venv_dir) == 1 then if vim.fn.isdirectory(venv_dir) == 1 then
vim.health.ok('Python virtual environment found at ' .. venv_dir) vim.health.ok('Python virtual environment found at ' .. venv_dir)
else else
vim.health.info('Python virtual environment not set up (created on first scrape)') vim.health.info('Python virtual environment not set up (created on first scrape)')
end
end end
local time_cap = utils.time_capability() local time_cap = utils.time_capability()

View file

@ -26,7 +26,8 @@ end
---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) } ---@param opts { sync?: boolean, ndjson?: boolean, on_event?: fun(ev: table), on_exit?: fun(result: table) }
local function run_scraper(platform, subcommand, args, opts) local function run_scraper(platform, subcommand, args, opts)
local plugin_path = utils.get_plugin_path() local plugin_path = utils.get_plugin_path()
local cmd = { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. platform, subcommand } local cmd = utils.get_python_cmd(platform, plugin_path)
vim.list_extend(cmd, { subcommand })
vim.list_extend(cmd, args) vim.list_extend(cmd, args)
local env = vim.fn.environ() local env = vim.fn.environ()
@ -43,7 +44,7 @@ local function run_scraper(platform, subcommand, args, opts)
local handle local handle
handle = uv.spawn( handle = uv.spawn(
cmd[1], cmd[1],
{ args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env }, { args = vim.list_slice(cmd, 2), stdio = { nil, stdout, stderr }, env = env, cwd = plugin_path },
function(code, signal) function(code, signal)
if buf ~= '' and opts.on_event then if buf ~= '' and opts.on_event then
local ok_tail, ev_tail = pcall(vim.json.decode, buf) local ok_tail, ev_tail = pcall(vim.json.decode, buf)
@ -102,7 +103,7 @@ local function run_scraper(platform, subcommand, args, opts)
return return
end end
local sysopts = { text = true, timeout = 30000, env = env } local sysopts = { text = true, timeout = 30000, env = env, cwd = plugin_path }
if opts and opts.sync then if opts and opts.sync then
local result = vim.system(cmd, sysopts):wait() local result = vim.system(cmd, sysopts):wait()
return syshandle(result) return syshandle(result)

View file

@ -121,13 +121,22 @@ function M.toggle_interactive(interactor_cmd)
end end
local orchestrator = local orchestrator =
vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p') vim.fn.fnamemodify(utils.get_plugin_path() .. '/scripts/interact.py', ':p')
cmdline = table.concat({ if utils.is_nix_build() then
'uv', cmdline = table.concat({
'run', vim.fn.shellescape(utils.get_nix_python()),
vim.fn.shellescape(orchestrator), vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor), vim.fn.shellescape(interactor),
vim.fn.shellescape(binary), vim.fn.shellescape(binary),
}, ' ') }, ' ')
else
cmdline = table.concat({
'uv',
'run',
vim.fn.shellescape(orchestrator),
vim.fn.shellescape(interactor),
vim.fn.shellescape(binary),
}, ' ')
end
else else
cmdline = vim.fn.shellescape(binary) cmdline = vim.fn.shellescape(binary)
end end

View file

@ -2,6 +2,8 @@ local M = {}
local logger = require('cp.log') local logger = require('cp.log')
local _nix_python = nil
local uname = vim.loop.os_uname() local uname = vim.loop.os_uname()
local _time_cached = false local _time_cached = false
@ -79,43 +81,116 @@ function M.get_plugin_path()
return vim.fn.fnamemodify(plugin_path, ':h:h:h') return vim.fn.fnamemodify(plugin_path, ':h:h:h')
end end
function M.is_nix_build()
return _nix_python ~= nil
end
function M.get_nix_python()
return _nix_python
end
function M.get_python_cmd(module, plugin_path)
if _nix_python then
return { _nix_python, '-m', 'scrapers.' .. module }
end
return { 'uv', 'run', '--directory', plugin_path, '-m', 'scrapers.' .. module }
end
local python_env_setup = false local python_env_setup = false
local function discover_nix_python()
local cache_dir = vim.fn.stdpath('cache') .. '/cp-nvim'
local cache_file = cache_dir .. '/nix-python'
local f = io.open(cache_file, 'r')
if f then
local cached = f:read('*l')
f:close()
if cached and vim.fn.executable(cached) == 1 then
_nix_python = cached
return true
end
end
local plugin_path = M.get_plugin_path()
local result = vim
.system(
{ 'nix', 'build', plugin_path .. '#pythonEnv', '--no-link', '--print-out-paths' },
{ text = true }
)
:wait()
if result.code ~= 0 then
logger.log('nix build #pythonEnv failed: ' .. (result.stderr or ''), vim.log.levels.WARN)
return false
end
local store_path = result.stdout:gsub('%s+$', '')
local python_path = store_path .. '/bin/python3'
if vim.fn.executable(python_path) ~= 1 then
logger.log('nix python not executable at ' .. python_path, vim.log.levels.WARN)
return false
end
vim.fn.mkdir(cache_dir, 'p')
f = io.open(cache_file, 'w')
if f then
f:write(python_path)
f:close()
end
_nix_python = python_path
return true
end
---@return boolean success ---@return boolean success
function M.setup_python_env() function M.setup_python_env()
if python_env_setup then if python_env_setup then
return true return true
end end
local plugin_path = M.get_plugin_path() if _nix_python then
local venv_dir = plugin_path .. '/.venv' python_env_setup = true
return true
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 end
if vim.fn.isdirectory(venv_dir) == 0 then if vim.fn.executable('uv') == 1 then
logger.log('Setting up Python environment for scrapers...') local plugin_path = M.get_plugin_path()
local env = vim.fn.environ() local venv_dir = plugin_path .. '/.venv'
env.VIRTUAL_ENV = ''
env.PYTHONPATH = '' if vim.fn.isdirectory(venv_dir) == 0 then
env.CONDA_PREFIX = '' logger.log('Setting up Python environment for scrapers...')
local result = vim local env = vim.fn.environ()
.system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env }) env.VIRTUAL_ENV = ''
:wait() env.PYTHONPATH = ''
if result.code ~= 0 then env.CONDA_PREFIX = ''
logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR) local result = vim
return false .system({ 'uv', 'sync' }, { cwd = plugin_path, text = true, env = env })
:wait()
if result.code ~= 0 then
logger.log('Failed to setup Python environment: ' .. result.stderr, vim.log.levels.ERROR)
return false
end
logger.log('Python environment setup complete.')
end end
logger.log('Python environment setup complete.')
python_env_setup = true
return true
end end
python_env_setup = true if vim.fn.executable('nix') == 1 then
return true 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 end
--- Configure the buffer with good defaults --- Configure the buffer with good defaults
@ -170,12 +245,8 @@ function M.check_required_runtime()
return false, 'GNU timeout not found: ' .. (timeout.reason or '') return false, 'GNU timeout not found: ' .. (timeout.reason or '')
end end
if vim.fn.executable('uv') ~= 1 then
return false, 'uv not found (https://docs.astral.sh/uv/)'
end
if not M.setup_python_env() then if not M.setup_python_env() then
return false, 'failed to set up Python virtual environment' return false, 'no Python environment available (install uv or nix)'
end end
return true return true

View file

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

View file

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

View file

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

View file

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

1269
uv.lock generated

File diff suppressed because it is too large Load diff