Release 0.6.0 through 0.7.0: security, reliability, and typing - #4
Merged
Conversation
Replace the hand-rolled CardHolderData class with a Pydantic v2 BaseModel,
gaining automatic validation, EmailStr checking, and model_dump-based
serialization in place of the manual to_dict construction.
Add pydantic>=2.0 and email-validator>=2.0 as dependencies, and an example
script demonstrating the validation behaviour.
BREAKING CHANGE: CardHolderData now requires keyword arguments. Positional
construction, e.g. CardHolderData("0912345678", "Wang", "a@b.com"), raises
TypeError. EmailStr is also stricter than the plain str field it replaces,
so addresses that were previously accepted may now fail validation.
See the migration guide in CHANGELOG.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Security: debug logging previously emitted the x-api-key partner key, the full request body (partner_key, prime, card_key, card_token and the entire cardholder block), and the raw response body including card_secret and card_info. Any application running at DEBUG level was writing payment credentials and cardholder PII to its logs. These values are now replaced with ***REDACTED*** before reaching the logger, matched case-insensitively at any nesting depth. Log records are built lazily behind isEnabledFor(), so no redaction or response parsing happens when debug logging is off. Requests previously had no timeout at all, so a stalled TapPay endpoint could block the calling thread indefinitely and exhaust a web application's worker pool. Client now takes a timeout argument defaulting to (3.05, 27.0) seconds, and every API method accepts a keyword-only timeout override. The parameter is keyword-only deliberately: **kwargs forwards into the request body, so a positional timeout would otherwise be posted as an API field. A sentinel distinguishes an omitted argument from an explicit None, keeping None's "no timeout" meaning intact at the constructor. Also: - Add a py.typed marker (PEP 561) so the type hints this library already ships are visible to consumers' type checkers instead of being ignored. - Source the version from installed distribution metadata. __version__ reported 0.5.2 while the package was 0.6.0; it is now declared only in pyproject.toml, so __version__, client.VERSION and the User-Agent header cannot drift apart. - AuthenticationError was raised as a bare class with no message; it now reports "401 response from <host>" like the other error branches. - Correct return annotations to Optional[Dict[str, Any]]; every method can return None on an HTTP 204. - Remove leftover development scratch notes from client.py. Test coverage rises from 73% to 92% (client.py 65% to 89%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the module-level requests.post with a per-client requests.Session, so repeated calls reuse an established TLS connection instead of paying for a fresh handshake each time. Add close() and context manager support to release pooled connections. Mount retries per-URL rather than session-wide. requests resolves adapters by longest matching prefix, so the read-only query endpoints pick up a retrying adapter (2 attempts, exponential backoff, 429/500/502/503/504) while everything else falls back to a non-retrying one. Payment, refund, capture, bind and remove endpoints are excluded deliberately: TapPay exposes no idempotency key, so retrying a request that actually succeeded upstream would charge the cardholder twice. A test asserts this directly against every write path so the guarantee cannot silently regress. TapPay reports declines and other business failures with an HTTP 200 and a non-zero status field, invisible to HTTP-level error handling. Add raise_on_error=True to raise TapPayError carrying .status, .msg and the full .response. Defaults to False to preserve existing behaviour. Add a keyword-only currency argument to pay_by_prime, pay_by_token and bind_card, still defaulting to TWD. Models.Currencies becomes a str-backed enum of 15 currencies; members compare equal to their string form and serialize as "TWD" through json.dumps, and plain strings remain accepted. Tooling: - Add a dev optional dependency group, [tool.mypy] config, and a mypy step in CI. CI is split into a quality job and a test matrix because current mypy releases cannot target Python 3.8. - Add Python 3.13 to the matrix and classifiers. - Raise the requests floor to 2.26 and add urllib3>=1.26; both are needed for the Retry(allowed_methods=...) API. - Drop --cov from pytest addopts so a bare pytest works without pytest-cov. - Correct the README badge from black to ruff. Test coverage rises from 92% to 100% (223 statements, 73 tests). Tests now patch client.session.post rather than tappay.client.requests.post. BREAKING CHANGE: downstream tests that mock tappay.client.requests.post will no longer intercept anything; patch client.session.post instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An intermediary such as a proxy, load balancer or WAF can answer with an HTML error page under a 2xx status, and an empty body decodes no better. Either case previously escaped as a bare json.JSONDecodeError raised from inside requests, with nothing to indicate which host or call produced it. Decode defensively and convert the failure into InvalidResponseError, reporting the status code, host, content type and body length, and keeping the original decode error as __cause__. It subclasses ServerError so that existing `except ServerError` handlers continue to catch it. The failure message deliberately omits the body. Exception text routinely reaches logs and error trackers, and an unparseable body cannot be redacted by key the way a JSON payload can, so a truncated preview would reopen the leak that 0.6.1 closed. A test asserts a malformed body containing a card token never appears in the message. Folded into the unreleased 0.7.0 rather than a new version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two failures, both caused by verifying with commands that differed from the ones CI runs. Test collection failed on every matrix entry with "No module named 'tests'". `python -m pytest` puts the current directory on sys.path but a bare `pytest` does not, and the test module imports shared helpers from tests.conftest. Local runs used the former, CI the latter. Set pythonpath = ["."] in the pytest configuration so both resolve. `ruff format --check .` failed on README.md and CHANGELOG.md. Ruff 0.16 began formatting Python code blocks inside Markdown; the local environment had 0.14.9, which ignores them. Add magic trailing commas to the affected examples so the formatter keeps them exploded rather than collapsing them onto one line, and strip the trailing whitespace the originals carried. Pin ruff to >=0.16,<0.17 in the dev extra. An unpinned formatter means a new release can break CI with no change to the repository, which is what happened here. Also ignore .gemini/, which is tool scratch output that ruff was picking up locally. Verified by running the exact CI commands (bare ruff, mypy and pytest) in a clean virtualenv built from the dev extra. Co-Authored-By: Claude Opus 5 <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.
Brings the library from 0.5.2 to 0.7.0 across four commits. The first lands the
Pydantic migration that was sitting uncommitted in the working tree; the rest
address findings from a review of the client.
Every commit passes its own tests in isolation, verified in a clean worktree.
Commits
c827699CardHolderDatato Pydantic v23129e5apy.typedfd412caraise_on_error, currency selection84076f6InvalidResponseErroron a malformed 2xx bodyHighlights
Credentials and cardholder PII no longer reach logs. Debug logging previously
emitted the
x-api-keypartner key, the full request body (partner_key,prime,card_key,card_token, and the entirecardholderblock), and the rawresponse body including
card_secretandcard_info. Any application running atDEBUGwas writing payment credentials and personal data to its logs. These arenow redacted before reaching the logger, matched case-insensitively at any depth,
and the log records are built lazily so nothing is computed when debug is off.
Requests now carry a timeout. There was none, so a stalled TapPay endpoint
could block the calling thread indefinitely and exhaust a web application's
worker pool. Defaults to
(3.05, 27.0)seconds, overridable per client and percall.
Retries are deliberately selective. Only the read-only query endpoints retry.
Payment, refund, capture, bind and remove endpoints never do: TapPay exposes no
idempotency key, so retrying a request that actually succeeded upstream would
charge the cardholder twice. A test asserts
max_retries.total == 0across everywrite path so the guarantee cannot silently regress.
Business failures can now surface as exceptions. TapPay reports declines with
an HTTP 200 and a non-zero
status, invisible to HTTP-level error handling.raise_on_error=TrueraisesTapPayError; it defaults toFalseso existingcallers are unaffected.
Connection pooling. A per-client
requests.Sessionreplaces module-levelrequests.post, so repeated calls reuse an established TLS connection ratherthan renegotiating one each time.
Also:
py.typed(PEP 561), a single-sourced version (__version__reported0.5.2while the package was0.6.0), currency selection,close()and contextmanager support, a
devextra, mypy in CI, and Python 3.13 in the matrix.Test coverage
73% to 100% (235 statements), 82 tests.
ruffandmypyclean.Breaking changes
CardHolderDatarequires keyword arguments (0.6.0). Positionalconstruction raises
TypeError.EmailStris also stricter than the plainstrfield it replaced. Migration guide is inCHANGELOG.md.tappay.client.requests.postwill no longer interceptanything (0.7.0). Requests now go through
client.session.post; patch thatinstead.
Models.Currenciesis now astrenum. Members still compare equal totheir string form and still serialize as
"TWD"throughjson.dumps, butstr()and f-strings render them asCurrencies.TWDon Python 3.11+, so amember must never be interpolated into a request payload. A test pins this.
Review notes
qualityjob (ruff + mypy on one interpreter) and atestmatrix. Current mypy releases cannot target Python 3.8, and type checking does
not need to repeat across every interpreter.
now actively constrains tooling. If it fails to resolve dependencies, that is
an argument for raising the floor to 3.9+ rather than working around it.
InvalidResponseErrormessages deliberately omit the response body. Exceptiontext routinely reaches logs and error trackers, and an unparseable body cannot
be redacted by key the way a JSON payload can.
Not included
Async client, response models, the Python 3.8 floor bump, and renaming the
_dict-suffixedget_recordsparameters.🤖 Generated with Claude Code