From 5b7c6a30da7b984dda938f79a05caa4e60887ed7 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Thu, 1 Oct 2026 12:56:27 +0000 Subject: [PATCH 1/2] Settle the public API before the first PyPI release - DXTradeClient is the class; DXTradeDashboardWrapper stays as an alias. - DXTradeConnectionError is the network-error class. The 0.1 name ConnectionError still imports but is no longer in __all__: after `from dxtrade_wrapper import *`, `except ConnectionError` stopped catching socket errors such as ConnectionRefusedError (verified). - logout() closes the requests Session before replacing it, so a bot that re-logs in for days does not leak one connection pool per session. - tests/test_public_api.py pins these; 5 of its 6 tests fail on the old code. Co-Authored-By: Claude Opus 5.5 --- dxtrade_wrapper/__init__.py | 8 ++--- dxtrade_wrapper/client.py | 27 ++++++++------ dxtrade_wrapper/exceptions.py | 10 +++--- example.py | 8 ++--- examples/offline_demo.py | 4 +-- tests/conftest.py | 4 +-- tests/test_public_api.py | 68 +++++++++++++++++++++++++++++++++++ tests/test_session.py | 4 +-- 8 files changed, 103 insertions(+), 30 deletions(-) create mode 100644 tests/test_public_api.py diff --git a/dxtrade_wrapper/__init__.py b/dxtrade_wrapper/__init__.py index e913644..8668c1e 100644 --- a/dxtrade_wrapper/__init__.py +++ b/dxtrade_wrapper/__init__.py @@ -6,11 +6,11 @@ import logging -from .client import DXTradeDashboardWrapper +from .client import DXTradeClient, DXTradeDashboardWrapper from .exceptions import ( AuthenticationError, ConflictError, - ConnectionError, + ConnectionError, # noqa: F401 - 0.1 name, importable but not exported by * DXTradeAPIError, DXTradeConnectionError, DXTradeWrapperError, @@ -25,9 +25,6 @@ __version__ = "0.2.0" -#: Shorter alias for the client class. -DXTradeClient = DXTradeDashboardWrapper - # Libraries must not configure logging; applications opt in with logging.basicConfig(). logging.getLogger("dxtrade_wrapper").addHandler(logging.NullHandler()) @@ -35,7 +32,6 @@ "AuthenticationError", "Balance", "ConflictError", - "ConnectionError", "DXTradeAPIError", "DXTradeClient", "DXTradeConnectionError", diff --git a/dxtrade_wrapper/client.py b/dxtrade_wrapper/client.py index de107e6..6b2b810 100644 --- a/dxtrade_wrapper/client.py +++ b/dxtrade_wrapper/client.py @@ -1,5 +1,5 @@ """ -DXTradeDashboardWrapper: a Python client for the DXtrade REST and Push APIs. +DXTradeClient: a Python client for the DXtrade REST and Push APIs. It covers authentication and session upkeep, account data, order management and real-time updates for a trading dashboard or bot. @@ -39,8 +39,8 @@ from .exceptions import ( AuthenticationError, ConflictError, - ConnectionError, DXTradeAPIError, + DXTradeConnectionError, NotFoundError, OrderPlacementError, PreconditionFailedError, @@ -97,13 +97,13 @@ def parse_interval(value: Any) -> Optional[float]: return None -class DXTradeDashboardWrapper: +class DXTradeClient: """ A client for the DXtrade REST API (token authentication) and Push API. Typical use:: - with DXTradeDashboardWrapper(base_url, username, password, "default") as dx: + with DXTradeClient(base_url, username, password, "default") as dx: print(dx.get_balance()) dx.place_order("EUR/USD", "BUY", 1000, "MARKET", stop_loss=1.05, take_profit=1.2) @@ -208,7 +208,7 @@ def __repr__(self) -> str: f"authenticated={self._is_authenticated})" ) - def __enter__(self) -> "DXTradeDashboardWrapper": + def __enter__(self) -> "DXTradeClient": self.login() return self @@ -326,7 +326,7 @@ def _send(self, method: str, url: str, context: str, json_body: Any, ) except requests.exceptions.RequestException as exc: # str(exc) carries the URL but never the body or auth header. - raise ConnectionError(f"{context}: network error: {exc}") from exc + raise DXTradeConnectionError(f"{context}: network error: {exc}") from exc @staticmethod def _retry_after(response: requests.Response) -> Optional[float]: @@ -360,7 +360,7 @@ def login(self) -> None: Raises: AuthenticationError: Wrong credentials (401) or API access refused (403). - ConnectionError: The server could not be reached. + DXTradeConnectionError: The server could not be reached. """ with self._auth_lock: payload = { @@ -378,7 +378,7 @@ def login(self) -> None: ) except requests.exceptions.RequestException as exc: self._is_authenticated = False - raise ConnectionError(f"Login: network error: {exc}") from exc + raise DXTradeConnectionError(f"Login: network error: {exc}") from exc if response.status_code == 403: self._is_authenticated = False @@ -431,6 +431,9 @@ def logout(self) -> None: self._request("POST", "logout", "Logout", retry_auth=False) except Exception as exc: # noqa: BLE001 - logout must always clear local state self._logger.warning("Logout request failed (ignored): %s", exc) + # Close the old pool: a bot that logs out and in again for days would + # otherwise leak one connection pool per session. + self._session.close() self._session = requests.Session() self._session.headers.update({"Accept": "application/json"}) self._auth_token = None @@ -672,7 +675,7 @@ def _post_order(self, body: JSON, order_code: str, context: str) -> Any: self._logger.debug("%s: %s", context, body) try: response = self._request("POST", path, context, json_body=body) - except ConnectionError as exc: + except DXTradeConnectionError as exc: cause = exc.__cause__ sent = not isinstance(cause, requests.exceptions.ConnectTimeout) raise OrderPlacementError( @@ -1327,7 +1330,7 @@ def _handle_push_message(self, channel: "_PushChannel", raw: str) -> None: class _PushChannel: """One Push API websocket with reconnect/backoff and subscription replay.""" - def __init__(self, client: DXTradeDashboardWrapper, name: str, url: str): + def __init__(self, client: DXTradeClient, name: str, url: str): self.client = client self.name = name self.url = url @@ -1417,6 +1420,10 @@ def send(self, message: JSON) -> None: raise WebSocketError(f"Failed to send {message.get('type')}: {exc}") from exc +#: The 0.1 name of :class:`DXTradeClient`, kept as an alias. +DXTradeDashboardWrapper = DXTradeClient + + def _version() -> str: from . import __version__ diff --git a/dxtrade_wrapper/exceptions.py b/dxtrade_wrapper/exceptions.py index 9434cf5..ddecfb4 100644 --- a/dxtrade_wrapper/exceptions.py +++ b/dxtrade_wrapper/exceptions.py @@ -13,7 +13,7 @@ class DXTradeWrapperError(Exception): - """Base exception for all DXTradeDashboardWrapper errors.""" + """Base exception for all DXTradeClient errors.""" class DXTradeAPIError(DXTradeWrapperError): @@ -96,12 +96,14 @@ def __init__( self.ambiguous = ambiguous -class ConnectionError(DXTradeWrapperError): # noqa: A001 - kept for backward compatibility +class DXTradeConnectionError(DXTradeWrapperError): """Transport failure: DNS, TCP, TLS or timeout. No server response.""" -#: Alias that does not shadow the builtin ``ConnectionError``. -DXTradeConnectionError = ConnectionError +#: The 0.1 name, kept so ``from dxtrade_wrapper import ConnectionError`` still works. +#: It is deliberately not in ``__all__``: ``from dxtrade_wrapper import *`` must not +#: replace the builtin ``ConnectionError`` in the caller's module. +ConnectionError = DXTradeConnectionError # noqa: A001 class WebSocketError(DXTradeWrapperError): diff --git a/example.py b/example.py index f4a742f..0264525 100644 --- a/example.py +++ b/example.py @@ -19,7 +19,7 @@ from dotenv import load_dotenv -from dxtrade_wrapper import DXTradeDashboardWrapper, DXTradeWrapperError +from dxtrade_wrapper import DXTradeClient, DXTradeWrapperError load_dotenv() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") @@ -35,7 +35,7 @@ def main() -> int: "in .env (see .env.example).") return 2 - client = DXTradeDashboardWrapper( + client = DXTradeClient( base_url=base_url, username=os.environ["DXTRADE_USERNAME"], password=os.getenv("DXTRADE_PASSWORD", ""), @@ -69,7 +69,7 @@ def main() -> int: return 0 -def demo_order(client: DXTradeDashboardWrapper) -> None: +def demo_order(client: DXTradeClient) -> None: """A tiny LIMIT buy at DXTRADE_DEMO_PRICE (default 0.5, far below market), then cancelled.""" symbol = SYMBOLS[0] price = float(os.getenv("DXTRADE_DEMO_PRICE", "0.5")) @@ -80,7 +80,7 @@ def demo_order(client: DXTradeDashboardWrapper) -> None: log.info("Cancel: %s", client.cancel_order(code)) -def stream_quotes(client: DXTradeDashboardWrapper, seconds: int) -> None: +def stream_quotes(client: DXTradeClient, seconds: int) -> None: client.connect_websocket() client.subscribe_market_data(SYMBOLS) client.subscribe_account_updates() diff --git a/examples/offline_demo.py b/examples/offline_demo.py index 4580eaf..be7b67e 100644 --- a/examples/offline_demo.py +++ b/examples/offline_demo.py @@ -12,7 +12,7 @@ import responses -from dxtrade_wrapper import DXTradeDashboardWrapper, OrderPlacementError +from dxtrade_wrapper import DXTradeClient, OrderPlacementError BASE = "https://dxtrade.example-broker.com" API = f"{BASE}/dxsca-web" @@ -56,7 +56,7 @@ def simulate(api: responses.RequestsMock) -> None: def main() -> None: with responses.RequestsMock(assert_all_requests_are_fired=False) as api: simulate(api) - with DXTradeDashboardWrapper(BASE, "demo", "demo-password", "default", + with DXTradeClient(BASE, "demo", "demo-password", "default", keepalive_interval=None) as dx: print("accounts :", dx.get_accounts()) print("balance :", dx.get_balance()) diff --git a/tests/conftest.py b/tests/conftest.py index 4e182d2..2f5a432 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) -from dxtrade_wrapper import DXTradeDashboardWrapper # noqa: E402 +from dxtrade_wrapper import DXTradeClient # noqa: E402 BASE_URL = "https://dxtrade.example-broker.com" PREFIX = "/dxsca-web" @@ -42,7 +42,7 @@ def make_wrapper(**kwargs): keepalive_interval=None, ) params.update(kwargs) - return DXTradeDashboardWrapper(**params) + return DXTradeClient(**params) def body(call): diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..053e802 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,68 @@ +"""The public surface that a first PyPI release locks in.""" + +import builtins +import socket + +import responses + +import dxtrade_wrapper +from conftest import API, fixture, make_wrapper + + +def test_star_import_does_not_shadow_builtins(): + namespace = {} + exec("from dxtrade_wrapper import *", namespace) + shadowed = [name for name in namespace if name != "__builtins__" and hasattr(builtins, name)] + assert shadowed == [] + + +def test_builtin_connection_error_still_catches_socket_errors_after_star_import(): + namespace = {} + exec( + "from dxtrade_wrapper import *\n" + "import socket\n" + "try:\n" + " socket.create_connection(('127.0.0.1', 1), timeout=1)\n" + " caught = None\n" + "except ConnectionError as exc:\n" + " caught = exc\n", + namespace, + ) + assert isinstance(namespace["caught"], (ConnectionRefusedError, socket.timeout, OSError)) + + +def test_old_names_still_import(): + from dxtrade_wrapper import ConnectionError as OldConnectionError + from dxtrade_wrapper import DXTradeDashboardWrapper + + assert DXTradeDashboardWrapper is dxtrade_wrapper.DXTradeClient + assert OldConnectionError is dxtrade_wrapper.DXTradeConnectionError + assert issubclass(OldConnectionError, dxtrade_wrapper.DXTradeWrapperError) + + +def test_every_exported_name_exists(): + for name in dxtrade_wrapper.__all__: + assert hasattr(dxtrade_wrapper, name), name + assert "ConnectionError" not in dxtrade_wrapper.__all__ + + +def test_repr_uses_the_new_name_and_hides_secrets(): + client = make_wrapper() + text = repr(client) + assert text.startswith("DXTradeClient(") + assert "s3cret" not in text + + +def test_logout_closes_the_http_session(api): + api.add(responses.POST, f"{API}/login", json=fixture("login_success")) + api.add(responses.POST, f"{API}/logout", json={}) + client = make_wrapper() + client.login() + old_session = client._session + closed = [] + old_session.close = lambda: closed.append(True) + + client.logout() + + assert closed == [True] + assert client._session is not old_session diff --git a/tests/test_session.py b/tests/test_session.py index 16e20c2..e5ce1ca 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -9,8 +9,8 @@ from conftest import ACC, ACCOUNT, API, PASSWORD, TOKEN, fixture, make_wrapper from dxtrade_wrapper import ( AuthenticationError, - ConnectionError, DXTradeAPIError, + DXTradeConnectionError, NotFoundError, RateLimitError, ServerError, @@ -44,7 +44,7 @@ def test_login_without_token_is_an_error(api): def test_network_failure_is_a_connection_error(api): api.add(responses.POST, f"{API}/login", body=requests.exceptions.ConnectTimeout("boom")) - with pytest.raises(ConnectionError): + with pytest.raises(DXTradeConnectionError): make_wrapper().login() From 43cfe8f655a6aa285c162b0afdd449f1d9cef056 Mon Sep 17 00:00:00 2001 From: Bogdan Truta Date: Thu, 1 Oct 2026 12:56:27 +0000 Subject: [PATCH 2/2] Make the sdist/wheel PyPI-ready; add a Trusted Publishing release workflow - MANIFEST.in: the sdist shipped tests/test_*.py without conftest.py or the JSON fixtures, so `pytest` in an unpacked sdist failed with 7 collection errors. It now also includes CHANGELOG, examples and scripts. - pyproject: Python 3.9-3.14 and OS classifiers; Source, Issues and Changelog URLs. - README: an Install section, absolute links (relative ones break on PyPI), accurate 401/403 wording, packaging and release notes. - scripts/check_release.py: the version has a CHANGELOG entry; with a tag, the tag matches __version__ and the entry is dated. CHANGELOG dates 0.2.0. - CI package job: check_release, twine check --strict, test the unpacked sdist, install the wheel in a clean venv and check py.typed. - release.yml (on a published GitHub release): build and check, attach the sdist and wheel to the release, then publish to TestPyPI and PyPI via Trusted Publishing, only when the repository variable PYPI_PUBLISH=true. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 21 ++++++++- .github/workflows/release.yml | 88 +++++++++++++++++++++++++++++++++++ CHANGELOG.md | 16 ++++++- MANIFEST.in | 8 ++++ README.MD | 46 +++++++++++++----- pyproject.toml | 14 ++++++ scripts/check_release.py | 47 +++++++++++++++++++ 7 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 MANIFEST.in create mode 100644 scripts/check_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 925a541..7b84367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,5 +44,22 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.13" - - run: pip install build && python -m build - - run: pip install dist/*.whl && cd /tmp && python -c "import dxtrade_wrapper; print(dxtrade_wrapper.__version__)" + - run: python scripts/check_release.py + - run: pip install build twine && python -m build + - run: twine check --strict dist/* + # The sdist must be self-contained: unpack it and run its own test suite. + - name: Test the sdist + run: | + mkdir /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist + cd /tmp/sdist/*/ + python -m venv /tmp/sdist-venv + /tmp/sdist-venv/bin/pip install -q ".[test]" + /tmp/sdist-venv/bin/python -m pytest -q + - name: Install the wheel in a clean venv + run: | + python -m venv /tmp/wheel-venv + /tmp/wheel-venv/bin/pip install -q dist/*.whl + cd /tmp && /tmp/wheel-venv/bin/python -c " + import importlib.resources, dxtrade_wrapper + assert importlib.resources.files('dxtrade_wrapper').joinpath('py.typed').is_file() + print(dxtrade_wrapper.__version__, dxtrade_wrapper.DXTradeClient)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6f712c2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +# Runs when a GitHub release is published (e.g. `gh release create v0.2.0`). +# It always builds, checks and attaches the sdist and wheel to the release. +# Uploading to TestPyPI and then PyPI happens only once the repository variable +# PYPI_PUBLISH is "true", i.e. after Trusted Publishing has been configured on +# test.pypi.org and pypi.org for this workflow (see README "Packaging"). + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Tag matches __version__ and has a dated CHANGELOG entry + run: python scripts/check_release.py "${{ github.event.release.tag_name }}" + - run: pip install build twine && python -m build + - run: twine check --strict dist/* + - name: Test the sdist + run: | + mkdir /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist + cd /tmp/sdist/*/ + python -m venv /tmp/sdist-venv + /tmp/sdist-venv/bin/pip install -q ".[test]" + /tmp/sdist-venv/bin/python -m pytest -q + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + attach: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Attach the sdist and wheel to the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" dist/* --repo "${{ github.repository }}" + + publish-testpypi: + needs: build + if: vars.PYPI_PUBLISH == 'true' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/dxtrade-python-wrapper + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true # a re-run must not fail on the version it already uploaded + + publish-pypi: + needs: publish-testpypi + if: vars.PYPI_PUBLISH == 'true' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/dxtrade-python-wrapper + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e4841..9a4dcd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,22 @@ # Changelog -## 0.2.0 (unreleased) +## 0.2.0 (2026-10-01) Rebuilt against the public DXtrade REST/Push specification (https://demo.dx.trade/developers/, OpenAPI at /dxsca-web/swagger/openapi.json). -Spec-conformant; not yet verified against a live broker. +Spec-conformant; not yet verified against a live broker. First release prepared +for PyPI. + +### Changed before the first PyPI release +- The client class is now `DXTradeClient`; `DXTradeDashboardWrapper` remains as an + alias. +- The network-error class is now `DXTradeConnectionError`. The old name + `ConnectionError` still imports, but is no longer in `__all__`: a star import used to + replace the builtin `ConnectionError` in the caller's module, so `except + ConnectionError` stopped catching socket errors. +- `logout()` closes the HTTP session's connection pool before starting a new one. +- The sdist now ships `tests/conftest.py`, the JSON fixtures, the examples and this + changelog, so its test suite runs. ### Fixed in independent review - `modify_order` on the entry (or a pending SL/TP) of an IF-THEN group sent a single diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..fe2b232 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +# setuptools only picks up tests/test*.py on its own; without conftest.py and the +# JSON fixtures the sdist's test suite cannot even be collected. +include CHANGELOG.md +include example.py +include .env.example +recursive-include tests *.py *.json +recursive-include examples *.py +recursive-include scripts *.py diff --git a/README.MD b/README.MD index 172991e..681e8d7 100644 --- a/README.MD +++ b/README.MD @@ -17,6 +17,20 @@ account updates. Prop-firm and retail traders use DXtrade. > **not** yet been run against a live broker. Try it on a **demo account** first. Brokers > decide whether to enable REST access at all (FTMO turned it off in April 2024). +## 📥 Install + +```bash +pip install dxtrade-python-wrapper # PyPI, once 0.2.0 is published +pip install git+https://github.com/Bogzx/DXtrade-python-wrapper # latest main +``` + +```python +import dxtrade_wrapper +``` + +Python 3.9+. Runtime dependencies: `requests` and `websocket-client`. Type hints ship with +the package (`py.typed`). + ## ⚡ Quick start (no broker account needed) ```bash @@ -55,8 +69,9 @@ with DXTradeClient( print(exc.status_code, exc.error_code, exc.description, exc.ambiguous) ``` -Or put your settings in `.env` (see `.env.example`), `pip install -e ".[examples]"` and -run `python example.py`. It is read-only unless you pass `--demo-order`. +Or put your settings in `.env` (see [`.env.example`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/.env.example)), +`pip install -e ".[examples]"` and run [`python example.py`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/example.py). It is +read-only unless you pass `--demo-order`. ## 📚 API @@ -86,13 +101,15 @@ instead of opening a second position. A read (GET) that is rate-limited (429) wa **Errors.** Everything derives from `DXTradeWrapperError`. Server errors are `DXTradeAPIError` subclasses carrying `status_code`, `error_code` and `description` from -DXtrade's error body: `AuthenticationError` (401/403), `NotFoundError`, `ConflictError` +DXtrade's error body: `AuthenticationError` (401, and a 403 at login, which means the +broker has not enabled REST access), `NotFoundError`, `ConflictError` (409 business rejection), `PreconditionFailedError` (412), `RateLimitError` (429, `retry_after`) and `ServerError`. `OrderPlacementError` adds `order_code` and `ambiguous`. `ambiguous=True` means the outcome is unknown (a timeout after sending, a 5xx, or an order group the server acknowledged only in part), so check for that -`orderCode` before sending again. `ConnectionError` (alias -`DXTradeConnectionError`) covers network failures. +`orderCode` before sending again. `DXTradeConnectionError` covers network failures. Any +other HTTP error, such as a 403 "Conditional request required", is a plain +`DXTradeAPIError` with its `status_code`. **Security.** The password and session token are never logged, and the token is not put in websocket URLs. The library does not configure logging; call @@ -138,7 +155,7 @@ missing `account`/`orderCode`. A later check against the public spec found more: invented field names in every parser, stop loss / take profit silently never placed, wrong endpoints and methods for history, modify, close and SL/TP, missing `If-Match` headers, a Push protocol that did not exist, and a balance that turned any parse error -into zeros. [`CHANGELOG.md`](CHANGELOG.md) has the details. +into zeros. [`CHANGELOG.md`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/CHANGELOG.md) has the details. Lessons that still apply: @@ -151,13 +168,18 @@ Lessons that still apply: 4. **Don't fabricate fallbacks.** An invented account code, zeros on parse failure, and SL/TP skipped with only a log line all turn a clear error into a silent wrong answer. -## 📦 Packaging +## 📦 Packaging and releases + +The distribution is `dxtrade-python-wrapper`; the import name is `dxtrade_wrapper`. The +sdist includes the tests and fixtures, so `pytest` runs from an unpacked sdist. Releases +are built by [`release.yml`](https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/.github/workflows/release.yml) +when a GitHub release is published. It checks that the tag matches `__version__` and has +a dated CHANGELOG entry, attaches the sdist and wheel to the release, and publishes them +with PyPI Trusted Publishing (TestPyPI first) once the repository variable +`PYPI_PUBLISH` is `true`. -Installable with `pip install .` or -`pip install git+https://github.com/Bogzx/DXtrade-python-wrapper`. It ships type hints -(`py.typed`). It is not published to PyPI. There is currently no package called -`dxtrade-sdk` on PyPI (an earlier version of this README recommended one). Community -SDKs exist on GitHub; they have not been evaluated here. +`DXTradeDashboardWrapper` (the 0.1 class name) and `ConnectionError` still import, as +aliases of `DXTradeClient` and `DXTradeConnectionError`. ## ⚖️ Disclaimer diff --git a/pyproject.toml b/pyproject.toml index 4c1fc50..3ca6e68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,17 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: Financial and Insurance Industry", + "Operating System :: OS Independent", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Office/Business :: Financial :: Investment", + "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] dependencies = [ @@ -31,6 +40,9 @@ examples = ["python-dotenv>=0.19"] [project.urls] Homepage = "https://github.com/Bogzx/DXtrade-python-wrapper" +Source = "https://github.com/Bogzx/DXtrade-python-wrapper" +Issues = "https://github.com/Bogzx/DXtrade-python-wrapper/issues" +Changelog = "https://github.com/Bogzx/DXtrade-python-wrapper/blob/main/CHANGELOG.md" "DXtrade API docs" = "https://demo.dx.trade/developers/" [tool.setuptools] @@ -54,5 +66,7 @@ extend-exclude = [".venv"] select = ["E", "F", "W", "B"] [tool.mypy] +# The oldest target current mypy accepts; Python 3.9 itself is covered by the CI +# test matrix. python_version = "3.10" ignore_missing_imports = true diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 0000000..85f86b3 --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,47 @@ +"""Checks that the package is ready to release as `tag` (e.g. v0.2.0). + + python scripts/check_release.py # the version has a CHANGELOG entry + python scripts/check_release.py v0.2.0 # ...dated, and the tag matches the version + +Run by CI on every push and by the release workflow before anything is uploaded. +""" + +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def package_version(): + text = (ROOT / "dxtrade_wrapper" / "__init__.py").read_text(encoding="utf-8") + return re.search(r'^__version__ = "([^"]+)"', text, re.M).group(1) + + +def changelog_entry(version): + """The heading line of `version` in CHANGELOG.md, or None.""" + text = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + match = re.search(rf"^## {re.escape(version)}\b.*$", text, re.M) + return match.group(0) if match else None + + +def problems(tag=None): + version = package_version() + found = [] + if tag is not None and tag.removeprefix("v") != version: + found.append(f"tag {tag} does not match __version__ {version}") + heading = changelog_entry(version) + if heading is None: + found.append(f"CHANGELOG.md has no '## {version}' section") + elif tag is not None and not re.search(r"\(\d{4}-\d{2}-\d{2}\)", heading): + found.append(f"CHANGELOG.md heading for {version} has no release date: {heading!r}") + return found + + +if __name__ == "__main__": + errors = problems(sys.argv[1] if len(sys.argv) > 1 else None) + for error in errors: + print(f"error: {error}") + if not errors: + print(f"ok: {package_version()} is ready to release") + sys.exit(1 if errors else 0)