feat(build): generate per-exchange Python packages from python/ccxt - #1
Open
szemyd wants to merge 6 commits into
Open
feat(build): generate per-exchange Python packages from python/ccxt#1szemyd wants to merge 6 commits into
szemyd wants to merge 6 commits into
Conversation
`import ccxt` imports all 105 exchanges from one entry point, so an application that talks to one venue pays for all of them. Add a generator that mechanically rewrites python/ccxt into ccxt-core plus one distribution per exchange, a verifier that proves each one imports in isolation and behaves identically to upstream, and CI that runs both. Nothing is hand-maintained per exchange - the exchange list, inheritance edges and package contents are all read out of the source tree, so the same command works on every future release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The six build jobs targeted self-hosted runners that this fork does not have, so every check sat queued forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--no-index also blocked ccxt-core's pinned certifi/requests/cryptography, which are not in the local dist directory. --find-links alone still resolves every ccxt-* package from the built wheels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
static_dependencies and protobuf are 1.7 MB of vendored code that base/exchange.py imports from inside the few methods that need it, so shipping it in ccxt-core put it on every install. 82 of the 108 exchanges never reach any of it. Each tree now gets its own distribution and an exchange package depends on what its call graph can reach. The grouping is derived rather than declared: directories reachable from the same base-method entry points travel together, which is why lark - only ever used to parse Cairo ABIs - ships with starknet, while keccak stays separate and both ethabi and starknet depend on it. ccxt-core drops from 1849 KB to 695 KB and the median single-exchange install from 2167 KB to 1023 KB. Because the imports are lazy, nothing about them shows up at import time, so verify_packages.py now executes them: for each exchange it runs the vendored import statements from the base methods that package actually calls, in a child interpreter that can see only its declared dependencies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dist_prefix and module_prefix can differ, so deriving one from the other would break any run that passes --dist-prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ccxt.exchanges lists 104 sync exchanges, not 105 - the earlier figure counted __init__.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
import ccxtruns one__init__.pythat imports all 104 exchanges. This PR adds a generator that mechanically splitspython/ccxtinto one PyPI distribution per exchange plus a shared core, a verifier that proves each package imports in isolation and behaves identically to upstream, and CI that runs both. No exchange code is hand-edited and the monolithic package keeps shipping unchanged.116 distributions generated, all 108 exchange packages verified byte-identical in behaviour to upstream, every wheel/sdist passes
twine check.Why
import ccxtimport ccxt_bit2cAcross all 108 exchanges the median single-exchange install is 1023 KB, down from 36 MB for the monolith and from 2167 KB before the vendored trees were split out.
import ccxt.progoes from 888 modules / 111 MB to 504 / 74 MB forccxt_binance.pro. The wins that matter most are the ones that don't fit in a table: an audit of a trading service no longer has to cover 103 exchange modules it never calls, and a CVE in one exchange's vendored dependency stops being everyone's problem.What is in the PR
python/split/split_packages.pypython/split/verify_packages.pypython/split/tests/test_split_packages.pyCCXT_SPLIT_FULL=1)python/split/README.md.github/workflows/python-split-packages.yml.gitignore,CLAUDE.mdpython/split-dist/, one line in the repo map.github/workflows/{js,python,php,cs,go-app,java}.ymlubuntu-latestinstead of self-hosted runners this fork does not haveNothing under
ts/src/,python/ccxt/, or any other generated tree is touched.How the split works
Every input is read out of the source tree, so the same command works on the next release with no edits:
__init__.pyexchangeslist; the version from__version__.ccxt.…module paths are rewritten over the token stream, not with a text regex. That is what keeps the ~6000ccxt.comURLs in docstrings anddescribe()blocks untouched while still rewritingclass binanceus(ccxt.async_support.binanceus).ccxt.base.*,ccxt.static_dependencies.*,ccxt.protobuf.*,ccxt.async_support.base.*→ccxt_core;ccxt.<id>and its flavour/abstract variants →ccxt_<id>.__init__.pyis the upstream one with the per-exchange import lines it does not own removed andexchangesshrunk. Licence header,__version__, error re-exports and__all__carry over verbatim.binanceus→binance,bequant→hitbtc,kucoinfutures→kucoin, …) depend on the parent distribution rather than vendoring it, discovered from the imports rather than a hard-coded table.isinstanceandexcepttherefore still work across packages, and because the error hierarchy lives inccxt_core,except ccxt_core.NetworkErrorcatches failures from every installed exchange.requires-pythonare inherited from the rootpyproject.toml;ccxt-coreinherits the pinned runtime dependencies; exchange packages depend only onccxt-coreplus any parent and vendored bundle.Useful flags:
--only binance,okx(parents pulled in automatically),--build(wheels + sdists),--dist-prefix/--module-prefixif the PyPI names need to differ,--vendored coreto put the vendored trees back insideccxt-core,--sourceto split a different checkout.The vendored trees
static_dependenciesandprotobufare 1.7 MB of third-party code thatbase/exchange.pyimports from inside the few methods that need it. Inccxt-coreit landed on every install, and 82 of the 108 exchanges never reach any of it:ccxt-core-starknetccxt-core-dydx-v4-clientccxt-core-msgpackccxt-core-protobufccxt-core-keccakccxt-core-ethabiccxt-core-lighter-clientThe grouping is derived, not declared. Each vendored directory gets a signature — the set of
base/exchange.pyentry points that reach it through the vendored import graph — and directories sharing a signature ship together. That is whylark(428 KB, and used only to parse Cairo ABIs) travels with starknet, whilekeccak, reachable on its own as well as throughethabiandstarknet, stays separate with both depending on it. Which exchange needs what comes from the base-method call graph, closed overself.…calls. It over-approximates where unsure — anything callinghash()getsccxt-core-keccak(7 KB) whether or not it passes'keccak'— because an extra dependency is harmless and a missing one is not.Cross-bundle relative imports inside the vendored code are rewritten to absolute ones:
starknet/hash/utils.pysaidfrom ... import keccakand now saysfrom ccxt_core_keccak import keccak.Result:
ccxt-corealoneccxt-bit2cccxt-krakenccxt-binanceccxt-paradex(needs starknet)Verification
verify_packages.pyimports each package in a fresh interpreter, instantiates the exchange in every flavour it ships, and asserts the onlyccxt_*modules left insys.modulesare the package itself,ccxt_core, and the parents it declares. A leak shows up as an extra module name rather than as an import time nobody measures.--compare-upstreamadditionally loads the monolithic package alongside and asserts thatdescribe(), the full attribute surface and the MRO of every split class match upstream exactly. That is the strongest claim here: for all 108 exchanges across all their flavours, the split class is the upstream class.Because the vendored imports are lazy, importing a package proves nothing about them — a wrong dependency would stay invisible until a user signed an order. So the verifier executes them: for each exchange it collects the vendored import statements from the base methods that package actually calls, and runs them in a child interpreter that can see only that package's declared dependencies. Deleting
ccxt-core-starknetfromccxt-paradex's dependencies makes it fail, as it should:Ran locally on this branch:
pytest python/split/tests— 24 passed (rewriter unit tests + subset trees)CCXT_SPLIT_FULL=1 pytest python/split/tests— 27 passed, full sweep over every exchangesplit_packages.py --build— 116 distributions, 232 artifactstwine check— all PASSEDccxt_binanceus.binanceus()works, loads exactly['ccxt_binance', 'ccxt_binanceus', 'ccxt_core']ccxt-paradex→retrieve_stark_account(...)derives a real Stark key from the lazily-imported vendored starknet coderuff check python/split/(repo config) — cleanThe subset used by the fast tests is chosen for coverage rather than convenience:
binance(largest, all three flavours),binanceus(cross-package inheritance),hyperliquid(the only id present in all four flavours),kalshi(prediction-only, no sync class),bit2c(the ordinary case),paradex(heaviest vendored bundle).The §6.5 checklist in
CLAUDE.mddoes not apply — this PR contains nots/src/change and so nothing to transpile.Publishing
Build output is gitignored; this PR does not publish anything and adds no release automation.
ccxt-coreand theccxt-core-*bundles have to land on the index first, since every exchange package pins them by exact version.Notes / decisions worth a second opinion
ccxt-core/ccxt-<id>/ccxt-core-<lib>on PyPI. Several may already be taken —--dist-prefix/--module-prefixexist precisely so this is a flag, not a rewrite.ModuleNotFoundErrorat call time. Three things bound that risk: the analysis over-approximates, the verifier executes every lazy import against the declared dependencies only, andccxt-coreexposes each bundle as an extra (pip install ccxt-core[starknet], or[all]) with--vendored coreas a full escape hatch.google.protobufis missing from ccxt's dependencies upstream, somexc's protobuf decoding anddydx's transaction encoding fail on a plainpip install ccxttoday. The verifier reports it separately rather than failing; fixing it is out of scope for this PR, butccxt-core-protobufandccxt-core-dydx-v4-clientwould be the natural place to declare it.base/into every package would make each one standalone, but two installed exchanges would then have two unrelatedNetworkErrorclasses andexceptwould silently stop working. The shared core is what keeps multi-exchange installs coherent.python/ccxt/testis deliberately not packaged — the upstream harness imports the whole library by design.ubuntu-latest(they targeted self-hosted runners this fork does not have, so every check sat queued). All sixbuildjobs pass. Theirlive-testsjobs fail against real venues from a GitHub-hosted IP — e.g.bitrueWS: "44 succeeded, 1 failed" — which is unrelated to this change and left alone rather than skipped.🤖 Generated with Claude Code