[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "refigure" version = "0.3.5" description = "DOCX/XLSX -> Markdown conversion with native OOXML chart-data extraction (no rasterize/OCR/VLM needed) + optional VLM interpretation for figures with no native chart data" readme = "README.md" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE"] requires-python = ">=3.10" authors = [{ name = "HelgDemidov", email = "helg.demidov88@gmail.com" }] classifiers = [ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Topic :: Text Processing :: Markup :: Markdown", ] dependencies = [ "lxml>=6.1,<7", ] [project.optional-dependencies] docx = ["mammoth>=1.12,<2", "markdownify>=1.2,<2"] xlsx = ["openpyxl>=3.1,<4"] # Stage 4b: DOCX composite-figure VLM interpretation (refigure/vlm.py), # gated behind Config.use_vlm — not active/announced in v1. pdfplumber is # NOT PDF conversion: it crops the PDF LibreOffice renders as an # intermediate step for one isolated mini-docx; neither PDF input nor PDF # output is otherwise supported anywhere in this package. Also requires the # system `soffice` (LibreOffice) binary, not installable via pip/extras — # its absence degrades to an honest warning, never a hard failure. vlm = ["pdfplumber>=0.11,<0.12"] # Direct-provider VlmClient implementations (OpenAIClient/AnthropicClient, # refigure/vlm/client.py) — deliberately NOT folded into [vlm] above: the # default OpenRouterClient path has zero HTTP dependencies (stdlib urllib # only), and must stay that way for anyone who never touches these two # classes. openai/anthropic share 7 of 8 required dependencies (checked # live via PyPI JSON, 2026-08-06) — one combined extra, not vlm-openai/ # vlm-anthropic split, since splitting barely reduces install weight but # doubles the extras-isolation CI matrix. # # Depends on [vlm] itself (self-referential extra, not just openai/ # anthropic) — NOT optional in practice: refigure/vlm/client.py is a # submodule of the refigure.vlm PACKAGE, so importing it always runs # refigure/vlm/__init__.py first (same rule that caused the docx_groups.py # extras-isolation bug, see project_extras_isolation_bug memory) — which # has its own module-level pdfplumber guard. Reproduced live, 2026-08-06: # `from refigure.vlm.client import OpenAIClient` with pdfplumber poisoned # raised "refigure[vlm] is required" before ever reaching OpenAIClient's # own openai-specific guard. Unlike the docx_groups.py case, this coupling # is NOT incidental — Config.vlm_client's only real call site # (enhance_docx_markdown) always renders figures via pdfplumber first, # regardless of which VlmClient ends up sending the result, so requiring # [vlm] here reflects a genuine dependency, not a bug to route around by # flattening client.py out of the package. vlm-direct = ["refigure[vlm]", "openai>=3.1.0,<4", "anthropic>=0.120,<2"] # MCP server (v2, stage 10) — thin protocol wrapper over docx.convert()/ # xlsx.convert(), no self-referential coupling to [docx]/[xlsx]/[vlm] # (unlike vlm-direct above): refigure/mcp/__init__.py's guard is the only # module-level dependency, a bare `refigure[mcp]` is valid with 0 tools # registered — see docs/mcp-server/mcp-server-architecture/ # mcp-server-architecture.md §2. mcp = ["mcp>=2.0,<3"] # Convenience meta-extra for one-shot `uvx --from 'refigure[full]' ...` # invocations — union of every capability extra, nothing of its own. Carries # no independent isolation contract: if any component subset breaks, the # per-extra CI legs above catch it first. Exists specifically because [mcp] # alone registers 0 tools (see comment above) and [vlm-direct] alone has no # format to convert — a useful refigure-mcp needs all four together, and # spelling that out by hand in every doc/README invocation got repetitive. full = ["refigure[mcp,docx,xlsx,vlm-direct]"] [project.scripts] refigure = "refigure.cli:main" refigure-mcp = "refigure.mcp.cli:main" [project.urls] Homepage = "https://github.com/HelgDemidov/refigure" Repository = "https://github.com/HelgDemidov/refigure" [tool.hatch.build.targets.wheel] packages = ["refigure"] [tool.hatch.build.targets.sdist] # Explicit include allowlist, same principle as the wheel target's # `packages = ["refigure"]` above but wider — sdist legitimately ships test # sources too (rebuild/retest from the sdist alone, without a git checkout, # is normal packaging practice), just not tests/integration/'s 144MB # fixture corpus, which needs a git checkout for its fixtures anyway. include = [ "refigure/**", "tests/unit/**", "tests/support.py", "tests/__init__.py", # Leading "/" anchors these to the project root — hatchling's include # patterns follow gitwildmatch (.gitignore) semantics, where a bare # filename with no "/" matches at ANY depth, not just the root. Without # the anchor, an unrelated non-gitignored file anywhere in the tree that # happens to share one of these names (e.g. a vendored/nested LICENSE) # would get silently swept into the sdist alongside the real one — # confirmed live during this PR's adversarial review. "/README.md", "/LICENSE", "/NOTICE", "/ATTRIBUTION.md", "/pyproject.toml", ] # Redundant with the include allowlist above in principle (nothing here # would match `include` anyway) — kept explicit: documents intent and # stays correct even if `include` is later loosened to a broader glob. exclude = [ "tests/integration/", "docs/", ".claude/", ".hypothesis/", "scripts/", "examples/", ] # `.gitignore` also ends up in the sdist despite matching nothing above — # NOT an `include`/`exclude` leak, confirmed by reading hatchling's own # source (SdistBuilder.get_default_build_data(), hatchling 1.32.0): # hatchling force-includes pyproject.toml, the readme, and any VCS ignore # file unconditionally, bypassing `include`/`exclude` entirely. Harmless # (no secrets, no size impact) and not overridable from here without # fighting a hatchling internal, so left as-is rather than "fixed". [tool.ruff] line-length = 100 target-version = "py310" [tool.ruff.lint] # S = flake8-bandit (SAST), added 2026-08-06 — ruff's own built-in port, chosen over # a standalone Bandit dependency: zero new install/CI-step for the same # practical coverage at this codebase's size. select = ["E", "F", "I", "S"] [tool.ruff.lint.per-file-ignores] # One-off asset-generation tooling (docs/assets/demo-*.svg), not part of # the package, not linted by CI (quality job runs `ruff check refigure # tests` only) — the long lines are inline SVG template markup, wrapping # them mid-tag would hurt readability, not help it. "scripts/gen_demo_asset*.py" = ["E501"] # S101 (bare `assert`) is pytest's own idiom, not a real risk — the whole # `S` category is noise inside tests/scripts, not code that ships. Verified # live (2026-08-06): 706 findings in tests/, almost all S101. "tests/**" = ["S"] "scripts/**" = ["S"] [tool.mypy] python_version = "3.10" strict = true explicit_package_bases = true [[tool.mypy.overrides]] # no type stubs published module = ["lxml.*", "mammoth.*", "mermaidx.*"] ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] markers = [ "mermaid: exercises a real mermaidx render (seconds, not ms, per test)", ] [tool.coverage.run] source = ["refigure"] # Needed to `coverage combine` data files collected across 2 separate CI # jobs (test-unit/test-integration) — makes file paths relative to the # repo root instead of absolute, so combine doesn't need a [paths] # remapping even though both jobs happen to check out to the same # runner-relative path today. relative_files = true [tool.coverage.report] exclude_also = [ "if TYPE_CHECKING:", ] # No fail_under here deliberately — it would also gate test-unit/ # test-integration's own standalone --cov runs (pytest-cov applies # coverage.py's fail_under automatically when no --cov-fail-under CLI flag # is given), silently coupling their pass/fail to a number neither job's # own suite alone is supposed to hit. The 95% gate is measured # against the COMBINED tests/unit + tests/integration suite specifically # — enforced via an explicit `coverage report --fail-under=95` in the new # "coverage" CI job (ci.yml) only, not here.