Development Guide¶
Everything you need to set up, test, and contribute to pycubrid.
Table of Contents¶
- Prerequisites
- Installation
- Project Structure
- Running Tests
- Offline Tests
- Sync/Async Replay Parity
- Integration Tests
- Code Coverage
- Docker Setup
- Code Style
- Makefile Commands
- CI/CD
- Documentation Structure
- Architecture Overview
- Adding a New Packet Type
- Adding a New Data Type
- Release Process
Prerequisites¶
| Requirement | Version | Notes |
|---|---|---|
| Python | 3.11+ | Matches requires-python in pyproject.toml |
| Docker | Latest | Only for integration tests |
| CUBRID Server | 10.2–11.4 | Via Docker or local install |
Installation¶
# Clone the repository
git clone https://github.com/cubrid-lab/pycubrid.git
cd pycubrid
# Install in development mode with the dev dependency group (pip 25.1+)
pip install -e . --group dev
# Or use the Makefile
make install
Dev Dependencies¶
| Package | Purpose |
|---|---|
pytest |
Test framework |
pytest-cov |
Coverage reporting |
ruff |
Linter and formatter |
Project Structure¶
graph TD
root[pycubrid/]
pkg["pycubrid/ - Main package"]
tests[tests/ - Test suite]
docs[docs/ - Documentation]
pyproject[pyproject.toml - Package configuration]
makefile[Makefile - Development commands]
compose[docker-compose.yml - CUBRID container setup]
changelog[CHANGELOG.md - Release history]
contributing[CONTRIBUTING.md - Contribution guidelines]
license[LICENSE - MIT license]
readme[README.md - Project overview]
root --> pkg
root --> tests
root --> docs
root --> pyproject
root --> makefile
root --> compose
root --> changelog
root --> contributing
root --> license
root --> readme
pkg --> init[__init__.py - Public API, PEP 249 module attributes]
pkg --> connection["connection.py - Sync Connection class"]
pkg --> cursor["cursor.py - Sync Cursor class"]
pkg --> aio["aio/ - asyncio Connection and Cursor"]
pkg --> compat["compat/ - opt-in CUBRIDdb and native compatibility layer"]
pkg --> conncommon[_connection_common.py - Logic shared by sync and async connections]
pkg --> cursorcommon[_cursor_common.py - Logic shared by sync and async cursors]
pkg --> prepared[_prepared_binding.py - Private typed bind encoders]
pkg --> types[types.py - PEP 249 type objects and constructors]
pkg --> exceptions[exceptions.py - PEP 249 exception hierarchy]
pkg --> errcodes[error_codes.py - CAS and CUBRID error codes]
pkg --> constants["constants.py - CAS protocol enums"]
pkg --> protocol["protocol.py - CAS packet classes"]
pkg --> packet[packet.py - PacketWriter + PacketReader primitives]
pkg --> lob["lob.py - LOB (BLOB/CLOB) support"]
pkg --> timing[timing.py - Optional timing statistics]
pkg --> typed[py.typed - PEP 561 marker]
tests --> conftest["conftest.py - Shared fixtures"]
tests --> unit["test_*.py - Behavior-oriented offline test modules"]
tests --> test_integration["Integration modules - Live DB tests (marked integration, require a live CUBRID server)"]
tests --> helpers["helpers/ and fixtures/ - Shared test support and golden data"]
docs --> doc_index[index.md - Documentation index]
docs --> doc_arch[ARCHITECTURE.md - Request flow and module boundaries]
docs --> doc_dev[DEVELOPMENT.md - This file]
docs --> doc_pages["Other guides - see the documentation index"]
The diagram is a map of the files, not a design document, and it deliberately omits counts that change. See ARCHITECTURE.md for module boundaries, the markers list in pyproject.toml for the pytest markers, and the documentation index for every guide.
Running Tests¶
Offline Tests¶
Most tests are offline — they mock the CUBRID connection and test packet serialization, cursor logic, type mapping, and exception handling without a database.
# Run all offline tests with coverage
pytest tests/ -v -m "not integration" \
--cov=pycubrid --cov-report=term-missing --cov-fail-under=95
# Or use the Makefile
make test
Backslash-escape-mode pin¶
tests/conftest.py autouse-pins no_backslash_escapes to True, CUBRID's
server default, for every test, because most tests build a Connection/AsyncConnection
over a scripted fake socket that cannot answer the live CHAR_LENGTH escape
probe. A module that needs the real probe (against a live server, or a
scripted fake broker that answers it) opts out with pytest.mark.no_escape_pin,
registered alongside the module's other markers, e.g.:
Opt-out used to be a hardcoded list of filename substrings, which silently
matched unrelated modules (#524, e.g. "test_integration" matched every
test_integration_*.py file). Mark the module explicitly instead of adding a
new filename fragment.
Fast Driver Tests vs. Repository Tooling Checks¶
Among the offline tests, a repo_tooling-marked subset (registered in
pyproject.toml) checks repository policy and tooling — the docs-sync script,
the PR-title validator, the release scripts, workflow-YAML contracts, the
shared quality gate, and similar (#558). These carry no pycubrid driver
behavior and are excluded from pycubrid's own coverage, so they run in the
dedicated repo-tooling-tests CI job instead of the offline-tests matrix,
keeping routine driver feedback fast. Tooling paths select this single Linux
lane; the CI Gate requires repo-tooling-tests and offline-tests to succeed
when selected, and permits only intentional unselected skips.
# Fast driver lane — mocked driver behavior only (what offline-tests runs)
pytest tests/ -m "not integration and not repo_tooling" -v
# Repository tooling lane — policy/tooling checks (what repo-tooling-tests runs)
pytest tests/ -m "repo_tooling" -v
# Both lanes together, still offline (no live CUBRID server)
pytest tests/ -m "not integration" -v
A module opts into the tooling lane with an explicit pytestmark = pytest.mark.repo_tooling,
not a file move or a path-based collection rule, so nothing needs reorganizing
on disk and nothing is silently dropped from pytest tests/ (every marker is
additive to the default collection; only -m selects or excludes it at run
time). docs-sync.yml runs test_docs_reason.py with a bare
python -m unittest discover and no dependency install, so that module (and
test_pr_title.py, at risk of the same thing) imports pytest in a
try/except ModuleNotFoundError and falls back to an empty pytestmark
when it is missing — the marker would be meaningless there anyway. A module
only ever run through pytest does not need this guard.
Pure scalar-formatting cases live in tests/test_param_security.py's shared
golden matrix, which checks both backslash modes without a connection (#563).
tests/test_aio_cursor_parity.py keeps small sync/async format/bind adapter
checks, including rejection before escape-mode negotiation. Ordinary recovery
tests prefer observable bound SQL and replay sessions; generation fences,
malformed replies and other unobservable safety invariants remain white-box tests.
Sync/Async Replay Parity¶
tests/test_replay_parity.py checks, offline and in a few seconds, that the
sync Connection and the async AsyncConnection behave the same against the
same broker. tests/helpers/replay_broker.py is a threaded in-process CAS
broker: it accepts any number of TCP sessions (so reconnects are real), answers
each request through a per-scenario script with deterministic default replies
built by tests/helpers/cas_reply.py, and records every request it receives.
Each scenario is a list of public operations (open, close, connect,
execute, fetchall, commit, ping, ...) plus a script. It is replayed
through both drivers, each against a fresh broker, and four aspects are
compared: step outcomes (return value or exception type), the exact requests
sent (session, CAS function, echoed CAS_INFO, arguments), whether the connection
is still usable afterwards (ping(reconnect=False)), and the number of TCP
sessions. A scenario names the aspects in which the drivers are meant to
differ, with a reason; every other difference fails. A known divergence that is
not fixed yet is recorded with unintended= and runs as a strict xfail. Each
scenario also carries a check run against both observations, so it cannot
silently stop exercising its path.
Covered: connect/close, reconnect after close, autocommit set/restore (explicit,
constructor, never set), handle invalidation by commit/rollback, the OUT_TRAN
CHECK_CAS probe, one CHECK_CAS recovery (with autocommit restore and
escape-mode re-probe) and a failed recovery, SQL bound to a replaced session
(generation fence, #471/#485), failed ping() with and without recovery,
malformed and truncated replies (#533), DataError keeping the session (#512)
and the fetch-page DataError contract (#536). Task cancellation exists only
in pycubrid.aio and is covered by tests/test_async_cancellation.py instead.
Per-operation round-trip budgets (#557): Observation.step_functions(i)
returns the exact, ordered CAS functions sent while running steps[i] alone —
separate from connect/setup (step 0, which includes any constructor
autocommit setter and the backslash-escape-mode probe) and from every other
step. FIRST_INSERT_BUDGET, REUSED_CURSOR_INSERT_BUDGET,
SELECT_TO_INSERT_BUDGET, MANUAL_INSERT_EXECUTE_BUDGET /
MANUAL_INSERT_COMMIT_BUDGET, FETCH_PAGINATION_BUDGET, and the
ESCAPE_EXPLICIT_* / ESCAPE_AUTOMATIC_* budgets name these exact sequences;
each is asserted with list equality, which catches a dropped safety request
(e.g. a missing CHECK_CAS liveness probe) exactly as it catches an added
round trip — neither can pass as an "optimization". Each budget also requires
successful operation outcomes and a reusable session; fetch scenarios check the
returned rows. This prevents a malformed reply or wrong result from passing just
because its request count stayed within the budget. Their scripts
(_autocommit_insert, _manual_insert_last_insert_id) reply to an INSERT's
PREPARE_AND_EXECUTE with an explicit OUT_TRAN (autocommitting: the
implicit transaction already committed) or IN_TRAN (manual: left open for
commit()) status, and give GET_LAST_INSERT_ID a well-formed value — the
broker's generic default reply for it is a bare response code, which the
driver (correctly) rejects as malformed. These scenarios are the
reproducibility baseline for future round-trip-reduction work (#419/#488/#525):
production optimization and CHECK_CAS removal are out of scope here.
The budgets above run against a broker that reports statement pooling off, where
deferred close (#488) never applies, so they are unchanged by it.
Scenario.statement_pooling=1 makes the broker report pooling on:
REUSED_CURSOR_INSERT_POOLED_BUDGET (the previous INSERT's CLOSE_REQ_HANDLE
and the CHECK_CAS gating it are gone: 4 requests instead of 6) and
SELECT_TO_INSERT_POOLED_BUDGET (CLOSE_REQ_HANDLE gone: 3 instead of 4) lock
in what deferred close removes, and their checks assert that the next
PREPARE_AND_EXECUTE carries exactly the released handle id.
Additional deferred-close scenarios in tests/test_deferred_close.py verify
queue overflow, transaction-boundary draining and reconnect safety.
To add a scenario, append a Scenario to SCENARIOS with its steps, a script
built from _on(...) (for example _hang_up_after_ok to recycle the CAS after a
reply) and a check.
Intended differences (not failures):
| Difference | Reason |
|---|---|
AsyncConnection(...) does not connect; await pycubrid.aio.connect(...) does |
__init__ cannot await |
Async autocommit is set with await conn.set_autocommit(v) |
a property setter cannot await |
| Every async I/O method is a coroutine | asyncio API |
Async create_lob() raises NotSupportedError and sends no LOB_NEW |
no async LOB support (scenario create_lob) |
| Task cancellation | async only; excluded from parity |
Unintended differences found by the harness (all fixed in #521):
| Difference | Fix |
|---|---|
Sync connect() after close() did not re-send an explicit autocommit (#520) |
connect() restores explicit session state on every replacement session |
Sync connect(autocommit=True) used the reconnecting property setter: an extra CHECK_CAS between SET_DB_PARAMETER and COMMIT, a CAS recycled there split them across sessions, and a failure raised the native error with the socket left open |
applied on the opened session only, with OperationalError on failure, like async |
Sync ping(reconnect=False) kept a session whose CHECK_CAS returned a negative code, and the next request reconnected silently |
the broken session is closed, like async |
Async escape probe on a CHECK_CAS replacement session sent auto_commit=0; every other escape probe (both drivers) sends the connection's flag |
the replacement probe sends the connection's flag |
A shared gap (no parity difference) was fixed at the same time: with automatic
escape detection, a CAS recycled right after the probe's ROLLBACK made
connect() fail before autocommit was applied; both drivers now verify that
OUT_TRAN session with CHECK_CAS first and replace it once.
Another shared gap was fixed in #551 (scenario
autocommit_setter_survives_recycle_after_set_db_parameter): the public
autocommit setter sent SET_DB_PARAMETER and its COMMIT on different CAS
sessions when the CAS was recycled between them. The new value is now recorded
before the COMMIT, so that request's single reconnect restores it on the
replacement session first; a failed COMMIT closes the connection and keeps the
previous value.
Mutation Testing¶
Line coverage proves code runs; mutation testing proves the tests catch wrong
behavior. The driver core (packet/protocol serialization, cursor/connection
lifecycle, LOB I/O) is configured under [tool.mutmut] in pyproject.toml, run
against the offline suite:
make mutation needs Python 3.12 or newer and stops with a message on 3.11:
mutmut 3.8 fails at stats collection there (#812). CI's weekly mutation job
uses 3.12.
Focus on meaningful surviving mutants (a flipped comparison or dropped cleanup that no test kills), not the raw score.
Integration Tests¶
Integration tests (marker integration) require a running CUBRID instance.
The simplest path is Docker through the Makefile:
make integration # broker published on localhost:33000
make integration CUBRID_TEST_PORT=33522 # same, on a port nothing else uses
make integration runs scripts/run_integration.py, which starts the compose
service, waits with scripts/wait_for_cubrid.py (fails the run if the broker is
not ready within about three minutes instead of sleeping a fixed time), runs
-m "integration and not tls" with every endpoint field set explicitly, audits
the JUnit report with scripts/check_integration_lanes.py --results (an
all-skipped or unclassified-skip run fails), and always removes what it created.
The exit status is the first failing step's; a failed docker compose down -v is
reported and fails an otherwise passing run.
TLS tests need an SSL-enabled broker; see
Async TLS integration tests.
Result files (#1024). make test writes test-results/test/junit.xml,
coverage.xml, summary.json and environment.json; make integration and
make integration-tls write test-results/integration/ and
test-results/integration-tls/ (junit.xml, summary.json, environment.json, and
service.log after a failed run). The directory is gitignored and make clean removes it.
CI writes the same layout for its offline, integration and TLS jobs, appends the one-line
summary to the job summary and uploads test-results/ as a 7-day artifact named
test-results-<lane>-py<python>[-cubrid<version>] on every run, pass or fail.
scripts/report_results.py builds summary.json (schema_version, lane, status,
exit_code, per-outcome counts, coverage) and prints the same status and counts as one
console line. status is PASS only when at least one test passed, none failed or
errored and the exit status was 0; FAIL when a test failed or the command exited
non-zero (including a missed coverage floor); INCOMPLETE when nothing proves the lane
ran (no JUnit file, nothing collected, every test skipped). A test that fails and also
errors in teardown counts once, with its worst outcome. environment.json holds the
Python, pycubrid and CUBRID image versions, the platform and the command, built from those
values only, never from the process environment, so an exported CUBRID_PASSWORD never
reaches a result file. Before the command is saved, the password of an authenticated URL
(cubrid://dba:***@host) and the value of a password, passwd, pwd, token, secret
or api_key key (--password=***, --password ***, CUBRID_PASSWORD=***) are masked.
This is a best-effort pattern match, not a secret scanner: a credential in any other form is
saved as given, so pass credentials through the environment, never on a command line. The files never change a
command's exit status. For the integration lanes summary.json is written after cleanup,
so it carries the final exit status (a failed down -v is FAIL), and saving service.log
is best effort (a warning, never a skipped cleanup). The run that holds the project lock owns
the files and removes the previous run's first; a refusal after the lock (existing project,
failed ownership probe) or an invalid INTEGRATION_PROJECT leaves a FAIL summary with a
reason, while a run refused because another run holds the lock writes and removes nothing. INTEGRATION_RESULTS=<path> moves the integration JUnit file, and
the other files go next to it. CI jobs do not run these targets; see
CI live jobs and the Make contract.
Verification summary (make verify, #1053). make verify execs
scripts/run_verify.py, which runs make check, make test and make integration one after
another, stops at the first failing phase and always writes test-results/verify/, also after a
failure or a signal (VERIFY_RESULTS=<dir> moves it). It first removes the previous run's files
there; make -n verify writes nothing.
| File | Content |
|---|---|
summary.json |
schema_version, lane (verify), overall status and problems, and one phases entry per phase in order (check, test, integration) with its status, exit_code, the lane summary it read and that summary's summary_status |
junit/test.xml, junit/integration.xml |
copies of the JUnit files of the lanes that ran and wrote one |
environment.json |
a copy of the live lane's environment.json, when it ran and wrote one |
A phase is PASS only when it exited 0 and its own summary.json (for test and
integration) is readable and says PASS; a phase that never started because an earlier one
failed is NOT_RUN. The overall status is FAIL when a phase failed, INCOMPLETE when a phase
did not run, a lane summary is missing, unreadable or has no string PASS/FAIL/INCOMPLETE
status, the lane proved nothing or a result file could not be copied, and PASS only when every
phase passed. The last console line repeats it, e.g. verify: PASS check=PASS test=PASS
integration=PASS -> test-results/verify/summary.json. make verify keeps the exit status of the
first failing phase, exits 128 + N after signal N, and exits 1 whenever the summary is not
PASS. Every copy has the values of CUBRID_PASSWORD, CUBRID_TEST_PASSWORD and
CUBRID_TLS_TEST_PASSWORD and the password of CUBRID_TEST_URL (also percent-encoded, XML- and
JSON-escaped) replaced with *** in their parsed text, attribute and string values, so the
copies stay valid XML and JSON (a file that does not parse is masked byte for byte), since a failing offline test can print them into its JUnit
report; the summary records no command, URL or environment variable.
The phases stay in the caller's process group, so Ctrl-C and a terminal hangup reach them as
usual. GNU make passes only SIGTERM on to a recipe (timeout make verify, kill <make pid>);
the runner forwards the first SIGINT, SIGTERM or SIGHUP it receives to the running phase's make
as SIGTERM, and make integration execs its runner, so the live lane still removes its Compose
project. The runner then starts no further phase and writes the summary. A SIGINT or SIGHUP sent
to make alone, outside a terminal, does not reach the runner.
Isolation from make docker-up (#1021). make integration and make integration-tls run in their own Compose project, pycubrid-it-<timestamp>-<pid> by default, and pass -p <project> on every docker compose call, so they never attach to, restart or remove the CUBRID started by make docker-up (an exported COMPOSE_PROJECT_NAME is ignored too). Set INTEGRATION_PROJECT=<name> (^[a-z0-9][a-z0-9_-]*$) to choose the name; the run first checks that the project has no container, volume or network and otherwise exits 1 with "Refusing to run" before starting or removing anything. A second run of the same name while the first is active is refused too ("another run owns project"), through a flock on a lock file in the temp directory held until cleanup ends (POSIX only; the guard is skipped on Windows). Because the project is the run's own, cleanup after success, readiness failure, test failure, SIGINT, SIGTERM or SIGHUP is docker compose -p <project> down -v: it also deletes the run's volumes, so every run starts from an empty database and leaves nothing behind. The manual lab's data volumes are never touched. make docker-down keeps the manual lab's data; only make docker-clean (down -v on the default project) removes it. Use make integration-local to test an existing broker without managing Docker. Cleanup also runs on SIGINT, SIGTERM and SIGHUP (the run stops the current step and exits 130), except that a make integration started as a background job (&) of a non-interactive shell ignores SIGINT, per POSIX, while SIGTERM still runs cleanup.
Enabling integration vs. choosing the endpoint. Integration tests are
enabled when CUBRID_TEST_URL or CUBRID_TEST_HOST is set to a non-empty
value. The endpoint is then resolved field by field by one shared helper,
tests/_cubrid_endpoint.py, used by every integration module, the
tests/conftest.py gate and scripts/wait_for_cubrid.py:
- the per-field variables
CUBRID_TEST_HOST,CUBRID_TEST_PORT,CUBRID_TEST_DB,CUBRID_TEST_USER,CUBRID_TEST_PASSWORDwin; - otherwise the matching component of
CUBRID_TEST_URL=cubrid://user[:password]@host[:port]/database; - otherwise the defaults
localhost,33000,testdb,dba, empty password.
An empty per-field variable counts as unset, except CUBRID_TEST_PASSWORD="",
which is an explicit empty password. A scheme-less CUBRID_TEST_URL (such as
1) only enables integration. A CUBRID_TEST_URL with a foreign scheme, no
host, a non-numeric port or a malformed database name makes every integration
test error (offline tests are unaffected). Exporting both the URL and the per-field
variables (as CI does) behaves exactly as before; a URL naming a non-default
host or port is now honored instead of silently testing localhost:33000.
To run against a server you already started (no Docker lifecycle), set the endpoint explicitly, preferably with the per-field variables:
CUBRID_TEST_HOST=127.0.0.1 CUBRID_TEST_PORT=33522 \
CUBRID_TEST_DB=testdb CUBRID_TEST_USER=dba CUBRID_TEST_PASSWORD= \
pytest tests/ -m "integration and not slow and not tls" -v
# equivalent: CUBRID_TEST_URL="cubrid://dba@127.0.0.1:33522/testdb" pytest ...
Skip vs. error. With no endpoint configured, integration tests skip so a
bare pytest stays green. With an endpoint configured, the gate probes it once
per session (SELECT 1, five-second timeouts); if it is unreachable, every
plain integration test errors with the endpoint (without the password) and
the connection error instead of skipping, and pytest exits non-zero. No test
module probes the server at import time. To skip integration tests, unset both
CUBRID_TEST_URL and CUBRID_TEST_HOST.
The CAS-recycling regressions in tests/test_integration_cas_reconnect.py
(#485) change broker parameters with broker_changer and run cubrid broker
reset inside the server container. They skip unless
CUBRID_TEST_DOCKER_CONTAINER names that container, for example
export CUBRID_TEST_DOCKER_CONTAINER="$(docker compose ps -q cubrid)".
make integration and the CI integration lanes set it; every change is
restored before the test returns.
Async TLS integration tests¶
tests/test_aio_ssl_integration.py adds async TLS coverage for pycubrid.aio,
and tests/test_tls_matrix_integration.py runs the TLS negative and lifecycle
matrix (unknown CA, hostname mismatch, plaintext/TLS refusal in both
directions, pinned TLS 1.2/1.3, ssl=True versus a caller SSLContext, read
timeout and dropped transport followed by a TLS reconnect, file-descriptor
leaks) against both the sync and async drivers. Both carry the integration and tls markers. The repository's
default docker-compose.yml starts a plaintext broker only, so these tests are
skipped unless you point them at a separate TLS-enabled broker.
The broker-independent half of the matrix (expired and self-signed
certificates, interrupted or stalled handshakes, TLS-version floors, downgrade
attempts on reconnect, no plaintext fallback) runs offline in
tests/test_tls_matrix_offline.py against an in-process OpenSSL peer
(tests/helpers/tls_broker.py), so it is part of make test. Its test PKI
lives in tests/fixtures/tls/; regenerate it with
tests/fixtures/tls/generate.sh. The async connect hang on an interrupted
handshake (#513) has its own offline regression suite,
tests/test_aio_tls_handshake_hang.py.
Export the normal integration variables plus these TLS overrides as needed:
export CUBRID_TLS_TEST_HOST=localhost
export CUBRID_TLS_TEST_PORT=33001
export CUBRID_TLS_TEST_DB=testdb
export CUBRID_TLS_TEST_USER=dba
export CUBRID_TLS_TEST_PASSWORD=
# Optional: private CA bundle for ssl.SSLContext/load_verify_locations().
export CUBRID_TLS_TEST_CA_FILE="$PWD/certs/ca.pem"
# Optional: alternate reachable host/IP for hostname-mismatch coverage.
export CUBRID_TLS_TEST_MISMATCH_HOST=127.0.0.1
# Optional: an SSL=OFF broker port on the same server (the stock query_editor
# broker listens on 30000) for TLS-client-to-plaintext-broker refusal coverage.
export CUBRID_TLS_TEST_PLAIN_PORT=30000
# If the broker uses a private CA, also point the process default trust store
# at it so the ssl=True cases (test_aio_ssl_connect_default_context and the
# ssl=True rows of the matrix) can verify the broker.
export SSL_CERT_FILE="$CUBRID_TLS_TEST_CA_FILE"
The optional variables only gate individual cases locally: a case skips only
when its configuration is missing. Once CUBRID_TLS_TEST_CA_FILE is set, a
broker that is unreachable or not serving TLS fails the tests instead of
skipping them. In CI every variable is set, and
scripts/check_integration_lanes.py fails the TLS lane on any skip in these
modules, so a lane that silently skips is red, not green.
Broker-side TLS must already be enabled (SSL=ON in cubrid_broker.conf) and
the broker certificate must match CUBRID_TLS_TEST_HOST. Then run:
Automated TLS coverage in CI¶
You do not need to run the steps above locally for routine development —
.github/workflows/integration-full.yml includes an integration-tls job
(Python {3.11, 3.14, 3.15} × CUBRID 11.4) that:
- Starts a CUBRID 11.4 container manually (so the broker config can be patched after the container is up).
- Generates a fresh self-signed certificate (
CN=localhost,SAN=DNS:localhost), injects it into the container ascas_ssl_cert.{crt,key}, then flipsSSL=OFF→SSL=ONforBROKER1and restarts the broker so the new cert is picked up. - Exports the generated CA bundle to the Python test fixture via
CUBRID_TLS_TEST_CA_FILEandSSL_CERT_FILE. - Probes the broker with a real TLS handshake and fails the job loudly if TLS is not actually serving — silent skips are explicitly rejected.
- Runs every
integration and tlstest (tests/test_aio_ssl_integration.pyandtests/test_tls_matrix_integration.py) against the TLS broker with theCUBRID_TLS_TEST_*env vars wired up automatically, includingCUBRID_TLS_TEST_PLAIN_PORT=30000for the container'sSSL=OFFquery_editorbroker.
The TLS lane requires every selected test to run, including hostname-verification failure; provisioning skips cannot pass the job. Broker status/restart commands run as the
cubridservice owner so the TLS job operates on the actual broker.
This job runs on the same triggers as the rest of integration-full
(via workflow_dispatch, and as the release gate called by publish-pypi.yml). ci.yml runs the same
lane per pull request as a single Python 3.14 × CUBRID 11.4 cell, and only when
TLS-relevant paths change (the connection modules, pycubrid/__init__.py,
pycubrid/protocol.py, pycubrid/aio/, the TLS and SSL tests,
tests/helpers/tls_*.py, tests/fixtures/tls/, the lane audit script or
workflows), so
routine PRs do not pay for it.
Code Coverage¶
Use make test for the current offline suite and its enforced 95% coverage
floor. Measured coverage comes from
individual full coverage runs; routine PR smoke and high-risk PR regressions do
not measure coverage. See CI execution policy for event selection.
Test counts change with the current tests
and are not fixed documentation baselines.
# Generate HTML coverage report
pytest tests/ -m "not integration" \
--cov=pycubrid --cov-report=html --cov-fail-under=95
# Open in browser
open htmlcov/index.html
Docker Setup¶
docker-compose.yml¶
services:
cubrid:
image: cubrid/cubrid:${CUBRID_VERSION:-11.2}
environment:
CUBRID_DB: testdb
CUBRID_DB_HOST: localhost
ports:
- "127.0.0.1:${CUBRID_TEST_PORT:-33000}:33000"
volumes:
- cubrid-data:/var/lib/cubrid
- cubrid-databases:/home/cubrid/CUBRID/databases
volumes:
cubrid-data:
cubrid-databases:
The full file also defines a healthcheck that runs csql -u dba testdb -c "SELECT 1".
The broker is published only on 127.0.0.1, so it is reachable from this host but not
from the network; CUBRID_VERSION (default 11.2) and CUBRID_TEST_PORT (default
33000) are the supported overrides. The service has no fixed container_name, so
separate Compose projects (COMPOSE_PROJECT_NAME or docker compose -p) can run side by
side on different ports.
CUBRID_DB_HOST: localhost makes the image register testdb for localhost in
databases.txt. Images up to 11.3 keep it and the database on the cubrid-data volume,
and 11.4 on cubrid-databases, so docker compose down keeps the database on every
supported version. Versions up to 11.3 share cubrid-data; a database created by one
CUBRID version is not guaranteed to open on another, so run make docker-clean (which
deletes the test data) before switching CUBRID_VERSION. Without it the image records the creating container's ID,
and the database stops working once that container is recreated
(Troubleshooting).
Commands¶
The Makefile wraps the manual lifecycle; each target passes CUBRID_TEST_PORT to Compose:
make docker-up # start CUBRID (127.0.0.1:33000)
make docker-up CUBRID_TEST_PORT=33522 # same, on another host port
make docker-down # remove container and network, keep the data
make docker-clean # DESTRUCTIVE: also delete the data volumes
# Start with default CUBRID 11.2
docker compose up -d
# Start with specific version
CUBRID_VERSION=11.4 docker compose up -d
# Check container status
docker compose ps
# View logs
docker compose logs -f cubrid
# Stop, keeping the data volume
docker compose down
# Stop and delete the data volume (removes all test data)
docker compose down -v
# Repair a volume created before CUBRID_DB_HOST was set (keeps the data)
make docker-fix-db-host
Connection Details¶
| Parameter | Value |
|---|---|
| Host | localhost |
| Port | 33000 |
| Database | testdb |
| User | dba |
| Password | (empty) |
Code Style¶
Ruff Configuration¶
Conventions¶
- Imports:
from __future__ import annotationsin every module - Type hints: Full typing; PEP 561 compliant (
py.typed) - super(): Always
super().__init__(), neversuper(ClassName, self) - Line length: 100 characters
- Docstrings: Google-style for all public methods and classes
- Naming:
- Classes:
PascalCase(e.g.,PacketWriter,ColumnMetaData) - Private methods:
_underscore_prefix(e.g.,_parse_byte,_write_int) - Constants:
UPPER_SNAKE_CASE(e.g.,CAS_INFO,DATA_LENGTH)
Linting¶
# Check for issues (neither command modifies files)
make lint # ruff check
make format-check # ruff format --check
# Auto-fix (the only mutating quality target)
make format
# Check tool pins and the active environment separately
make tooling-check
Make and regular/maintenance CI share the LINT_PATHS list in Makefile:
pycubrid tests scripts demos examples agent-evals. Ruff CLI discovery and hook types are explicitly
Python/pyi-only; Markdown is outside this formatting contract. Hooks use the same maintained-file scope;
Mypy remains strict on the package. make typecheck and CI also check the
tests/typing_self_contract.py static contract; the pre-commit hook checks pycubrid/ only. The tools run through the active Python
environment, so install the dev dependency group and activate it before running checks.
The lint dependency group in pyproject.toml owns the exact Ruff/Mypy versions. The Ruff and Mypy pre-commit
hooks are repo: local / language: system hooks that invoke python3 -m ruff
and python3 -m mypy against that same active environment, so there is no
separate hook revision to keep in sync: bumping the lint group pin (Dependabot's pip
ecosystem does exactly this) and reinstalling the dev group is enough. Activate that
environment (or a venv where it's installed) whenever a commit should run the
hooks; otherwise Ruff/Mypy are missing or a stale/global version silently runs
instead of the pinned one. Update the lint group pin, reinstall the dev group, and run
make check plus pre-commit run --all-files. make tooling-check rejects
pin, installed-version, hook-scope, and CI-scope drift before
lint/format/typecheck.
Anti-Patterns (Never Do)¶
- No f-string interpolation in SQL queries (SQL injection risk)
- No
super(ClassName, self)— usesuper()only - No Python 2 constructs
- No empty
exceptblocks (except in cleanup paths likeclose()) - No type suppression (
# type: ignorewithout explanation)
Makefile Commands¶
| Command | Description |
|---|---|
make help |
List the targets, grouped by purpose |
make test |
Run offline tests with coverage; writes test-results/test/ (JUnit, coverage, summary.json) |
make verify |
Full verification: make check, make test, then the live make integration lane; needs Docker, fails when the lane cannot run or every live test skips, and always writes test-results/verify/ |
make install |
Install in dev mode with all dependencies |
make doctor |
Check the required tools; fails if one is missing or broken |
make lint |
Run ruff check (never modifies files) |
make format-check |
Run ruff format --check (never modifies files) |
make format |
Auto-fix lint and formatting issues (the only mutating quality target) |
make check |
Static quality gate: lint, format-check, typecheck, security, docs-reason-check (make check-all is a deprecated alias) |
make integration |
Docker (run-owned Compose project) → readiness wait → integration tests → skip audit → cleanup (CUBRID_TEST_PORT=<port> to move the broker, INTEGRATION_PROJECT=<name> to name the project) |
make integration-local |
Integration tests against an already-running server (CUBRID_TEST_URL or CUBRID_TEST_HOST/PORT) |
make clean |
Remove build artifacts and test-results/ |
CI/CD¶
The regular and full integration workflows run python scripts/wait_for_cubrid.py
before the tests. It resolves the endpoint exactly like the test suite (per-field
CUBRID_TEST_HOST, CUBRID_TEST_PORT, CUBRID_TEST_DB, CUBRID_TEST_USER,
CUBRID_TEST_PASSWORD, then CUBRID_TEST_URL, then localhost:33000/testdb,
user dba, empty password), executes SELECT 1, and
fails the job if no probe succeeds within 30 attempts with 5 seconds between
attempts. Infrastructure failure therefore stops the test step.
Each connection and read has a five-second timeout, configurable through
CUBRID_TEST_CONNECT_TIMEOUT and CUBRID_TEST_READ_TIMEOUT. A connected broker
whose SELECT 1 fails is not ready. The cursor and connection are closed on both
success and failure, including cursor-cleanup errors.
Integration tests are assigned by pytest markers, with no fixed test-count or filename-glob inventory:
| Lane | Selection | Executable workflow path |
|---|---|---|
| Normal | integration and not slow and not tls |
Regular PR/push CI, full compatibility matrix, and weekly bug hunt |
| Slow | integration and slow and not tls |
Weekly/manual bug hunt: soak, chaos, concurrency stress, and the large prepared batch measurement |
| TLS | integration and tls |
Dedicated TLS jobs in regular CI and the full workflow |
| Official differential | integration and official_differential |
Required official-differential job (Python 3.11, CUBRID 10.2 and 11.4) in regular CI and the full workflow |
The large prepared batch measurement (#854) runs 10,000 and 100,000 rows of
executemany(..., prepared=True), sync and async, with a list and a one-shot
generator. It is slow, so run it on demand against one server; -s prints the
measured numbers. The script alone takes any row counts and writes JSON:
CUBRID_TEST_URL=cubrid://dba@localhost:33000/testdb \
pytest tests/test_integration_prepared_large_batch.py -m "integration and slow" -s
CUBRID_TEST_URL=cubrid://dba@localhost:33000/testdb \
python scripts/measure_prepared_batch.py --rows 10000 100000 --json prepared-batch.json
The wide-value and two-connection measurement (#881) is run the same way. The slow
test runs 1,000 rows per connection for every value profile and mode; the script
takes --rows (per connection), --profiles and --modes:
CUBRID_TEST_URL=cubrid://dba@localhost:33000/testdb \
pytest tests/test_integration_prepared_wide_rows.py -m "integration and slow" -s
CUBRID_TEST_URL=cubrid://dba@localhost:33000/testdb \
python scripts/measure_prepared_wide_rows.py --rows 10000 --json prepared-wide-rows.json
See PERFORMANCE.md for the recorded results.
python scripts/check_integration_lanes.py collects the current marker inventory
and checks that each lane has an executable workflow command. The JUnit audit
(--results FILE) fails unknown skips or empty/all-skipped runs. Outside its own
lane the official-driver differential skips as official-lane-only, and platforms
without /proc have an explicit skip category. Missing broker/TLS configuration
is not an accepted CI skip. --lane official accepts no skip at all.
The official-driver differential (#446) compares pycubrid with the official
CUBRIDdb/_cubrid driver built from pinned source. To reproduce it locally
(Linux x86_64, git, CMake 3.21 or newer, a C compiler and Python 3.11 headers),
run:
python3.11 scripts/build_official_oracle.py --out .official-oracle
PYTHONPATH=.official-oracle PYCUBRID_OFFICIAL_ORACLE_REQUIRED=1 \
PYCUBRID_OFFICIAL_ORACLE_MANIFEST=.official-oracle/oracle.json \
PYCUBRID_DIFFERENTIAL_EVIDENCE=official-evidence.jsonl \
CUBRID_TEST_URL=cubrid://dba@localhost:33000/testdb \
python3.11 -m pytest tests/ -m "integration and official_differential"
python scripts/check_official_differential.py --evidence official-evidence.jsonl
A new or changed claim goes in tests/fixtures/official_differential_claims.json.
Its case goes in CASES in tests/test_official_differential.py. Then run
python scripts/check_official_differential.py --write-docs. A divergence is
either fixed, or recorded as a deviation with a reason, an issue and both
observed values. Never edit an expected value just to match current output. See
the compatibility guide.
The weekly bug hunt also retains separate offline protocol, fault-broker, and
placeholder checks under the wider Hypothesis profile.
Version-differential session isolation (#614). The harness checks liveness
with ping(reconnect=False) so recovery cannot hide a dead session. It replaces
a dead session before raising SessionLost, allowing the next workload to use
a fresh connection while preserving scratch-table tracking. compare() tries
every endpoint and reports all lost sessions together. Losing a session still
fails the current test; an ordinary SQL error that leaves the session alive
does not open a replacement. The offline regressions are in
tests/test_version_differential_isolation.py.
The grammar excludes collection/numeric branch pairs from IF and CASE WHEN
expressions because of this minimal CUBRID client crash:
The original measurements in
#614 report csql SIGSEGV
(exit 139), without pycubrid, on builds 10.2.18.9024-01b54fa,
11.0.16.0419-862b3af, 11.2.9.0866-ef544a1 and 11.4.6.1963-0e7d3c1. The database
server survives. The measured trigger selects a NUMERIC branch of scale >= 2
beside a collection; the exclusion intentionally covers the broader numeric
family. Only IF was measured; excluding CASE WHEN is precautionary. Original
CI observed the CAS session loss on 10.2; four-version csql reproduction does
not establish four-version direct CAS verification.
The isolation and exclusion were merged in
#618 and
#668. The
full matrix at main 7fba209
passed all 27 jobs, including the four-version differential. That run validates
the mitigated lane, not a fix to the upstream crash or absence of other fatal
forms. #675 separately tracks
filing the report in CUBRID's official Jira (CBRD / Correct Error); no upstream
ticket has been filed by this follow-up. Remove _is_fatal_conditional only
once a verified fixed build is the oldest supported CUBRID build. The retained
transport-loss contract is documented in the
connection guide.
Bug-hunt failure diagnostics (#655). The weekly/manual representative
property-and-fault job writes normal-results.xml, slow-results.xml and
offline-results.xml with per-command junit_family=xunit1. On failure,
scripts/collect_repro.py reads all three through repeated --junit arguments.
The existing metadata.json keeps its environment fields and adds reports,
failures, server_identity and collection_errors. Missing, malformed,
read-error, zero-case and oversized reports remain explicit diagnostic states,
not evidence that a test lane passed. Each XML input is bounded to 10 MiB;
raw XML is not copied into the reproduction bundle.
Failure/error records preserve available parameterized nodes as exact,
collection-only file identities as file, or explicit unresolved reasons.
Missing, custom, mismatched or redacted identities are not guessed from a
classname, and never trigger a broad fallback replay. reproduce.md quotes
each argv token and fixed environment assignment value with shlex, deduplicates
available targets and asks for the password separately. Without JUnit, an accepted
caller-supplied legacy target remains a hint, not a verified report identity. Known configured
passwords, decoded/encoded URL-password variants and recognized credential-bearing URLs
are redacted before each failure record's message/detail is limited to 64 KiB
of UTF-8 bytes. A valid CUBRID_TEST_URL of up to 2048 characters is recorded
as user:***@host:port. It is recorded as <unparseable-url-redacted> when urllib
cannot parse it, when any @ lies outside the authority urllib parsed (for
example u:pw@host/db or cubrid:u:pw@host/db without ://, or a password
containing /, ?, # or @), or when it is longer than 2048 characters.
For such a URL, or one containing a tab, CR or LF (which urllib strips), the
text after each : before each @, and each piece and suffix of that text split
at /, ?, # and @ that is at least 3 characters long, is redacted raw and
percent-encoded/decoded in free text. Within the first 2048 characters, the text
after the first and the last : before the first @, and each piece (not each
suffix) of the userinfo up to the last @, are always candidates. Each URL-derived candidate is also
redacted as written (not percent-encoded) with CR and CRLF turned into LF (as
an XML parser reads element text), with tabs, CRs and LFs removed (as urllib
strips them) and repr()-escaped (as a traceback quotes it). A lowercased form of 3 or more characters is
redacted only as a whole word, the way urllib puts a lowercased host into
resolver and test-gate text (dba@***:33000/testdb). So is each host-shaped
piece of a candidate (split at /, ?, #, @, %, [ and ], without a
trailing :port) of 3 or more characters, because urllib rewrites the port,
drops IPv6 brackets and lowercases only the part before a %zone
(dba@***%Ab12:33000). These derived fragments
may over-redact unrelated diagnostic text; shorter derived fragments are not
redacted (#777). A URL longer than 2048 characters, or one that would yield more
than 1024 candidate fragments or 8192 candidate characters, keeps the candidates
found in its first 2048 characters before that limit and is also redacted as a
whole string, together with the password urllib parses from it and any
scheme://user:password@ password it contains. CUBRID_TEST_PASSWORD is
matched exactly, raw and percent-encoded, without these variants. Both limits
are a hard cap: they are checked before each candidate is added, so enumeration
stops at the limit even inside a single long tail. A password
fragment lowercased inside a longer word, or transformed in any other way, is
not guaranteed to be redacted. The endpoint resolver is
not trusted for a URL recorded as <unparseable-url-redacted>, because urllib
may place password fragments in the host (lowercased), user or database: its
replay command and server identity are then recorded as unavailable. Endpoint
fields and the readiness endpoint are rejected when they contain a known
password case-insensitively. A JDBC-style URL without @
(jdbc:cubrid:host:33000:db:u:PW:) is not recognised as credential-bearing:
its password is recorded as written unless it is also set in
CUBRID_TEST_PASSWORD. Endpoint-resolution errors are reported as a fixed
message, never as resolver text. This does not guarantee sanitizing arbitrary
secrets or data.
Optional readiness --server-info PATH records the actual engine version or
an unavailable reason. The workflow uses a fresh run-ID/run-attempt path
outside the uploaded bundle; the collector checks the recorded SHA and endpoint
and never substitutes an image tag/requested family for an observed version.
The new version lookup adds no reconnect or new connection: it uses the
successful SELECT 1 probe's connection with reconnect disabled and the physical
generation captured before cursor cleanup. Existing cursor-close behavior is
unchanged; if that cleanup replaces the session, the metadata request rejects
the changed generation and records unavailable rather than observing the
replacement. The collector does not contact a broker. Metadata lookup/write
failures cannot replace the original readiness or pytest outcome. Collector exit 0
means best-effort collection was attempted, not test success or a complete bundle; inspect recorded
collection errors and stderr for unavailable writes.
The saved Hypothesis database is still copied as binary examples, not guaranteed credential-sanitized. Trusted review is required before sharing the bundle externally or running its replay instructions. No filesystem sandbox, universal credential-safety or broader CI/migration guarantee is implied.
Its downstream-corpus job is advisory (weekly or manual only), with three
isolated CUBRID 11.4 / Python 3.12 cells. It checks out the exact pycubrid
workflow commit and the current main commit of each downstream repository in
different directories. Every dependency install is constrained to the driver
Git commit; after the final install, scripts/downstream_corpus.py rejects a
different Git origin/commit, a PyPI/local replacement or an import shadowed by
the checkout. The existing scripts/wait_for_cubrid.py checks the live broker.
| Downstream | Selected real workload | Accepted skips |
|---|---|---|
sqlalchemy-cubrid |
ORM dogfood and sync/async pool-stress files, in separate pytest runs | None |
cubrid-mcp-server |
Live integration tool cases plus a separately selected shared-session concurrency case | Empty-schema skips only in the general mcp.xml report; none in mcp-concurrency.xml |
cubrid-cookbook-python |
Five AI-agent scripts (including MCP stdio) and the async-worker database tasks, in separate pytest processes | None |
Each selected workload needs at least one passed JUnit case; a missing report,
failed case, unexpected skip or all-skipped workload fails that advisory cell.
The step summary and always-uploaded artifact record the driver and downstream
commit hashes, Python and installed package versions, driver origin, actual
CUBRID server version, and per-workload pass/skip/failure counts. Job-level
continue-on-error keeps
this exploratory corpus out of PR and release gates, but the evidence states
failure rather than claiming a false success. This is a bounded sample of real
downstream behavior, not those repositories' complete suites.
The MCP step also selects
tests/test_integration.py::TestCubridIntegration::test_concurrent_tool_calls_serialize_shared_session
in a second pytest process and writes mcp-concurrency.xml. That report must
contain a passed case and no skips, failures or errors, even if a skip reason
would be accepted in the general mcp.xml report. Both runs execute, and either
pytest failure makes the recorded step outcome fail. The selected case exercises
simultaneous in-process MCP handlers sharing one cached physical Database
connection through its existing RLock: trace and query work must not interleave,
responses must remain distinct and correct, and real cursors must close. This
bounded claim does not cover MCP stdio concurrency, pooling, per-request
transaction isolation or throughput; SQLAlchemy supplies the separate pool-stress
workload.
tests/test_protocol_fuzz.py mutates realistic broker replies built by
tests/helpers/cas_reply.py (#523): execute and FETCH replies with column
metadata and populated rows for every common type, plus schema, batch and LOB
replies. Each seed records its expected decoded values and the offsets of its
length words, counts and field boundaries, so the unmutated seed is an exact
round-trip check and mutations aim at truncation and length/count mismatches.
To seed a new column mix, add a ResultSet to RESULT_SETS; the FETCH and
execute targets pick it up. A new reply builder needs its own round-trip test
and fuzz target. Example budgets come from the Hypothesis
profile (pr: 50 examples per target, about 2 s for the module; nightly: 1000).
Documentation exceptions and contributor validation¶
The docs gate accepts a populated Docs: not needed - reason on a standalone
physical source line outside quotes, comments and code fences. It may be adjacent
to ordinary prose without its own paragraph. The existing docs-not-needed label
remains a separate exception.
Markers may have zero to three leading spaces; indented code and raw HTML quote,
preformatted or code blocks remain examples rather than authorizations.
Run make docs-reason-check for helper doctests and real event-JSON workflow cases;
these checks also run in make check and docs-sync CI.
Contributors report actual commands/results and checks not run with reasons;
optional AI review is recorded separately. Maintainers coordinate internal reviews,
actual issue labels and explicitly approved translations-deferred follow-ups.
Translation requests in a PR body do not grant approval. Korean README sync remains
required; other community translations remain advisory.
English under docs/ is the source of truth and Korean under docs/ko/ is the one
translation kept complete. python scripts/check_docs_translation.py (run by the
lint job) compares every docs/<name>.md with docs/ko/<name>.md and fails on a
missing Korean file or a different number of headings (levels 2-4), fenced code
blocks or table rows. Add the Korean section in the same PR as the English one.
A document that is deliberately English-only goes into EXCEPTIONS in that script
with its reason. The check compares structure, not wording.
The shared doc-lint and CodeQL callers use reviewed commit SHAs with verified
workflow_call inputs. Existing permissions, advisory rollout and required gates
remain intact. The doc-lint workflow still downloads main-based config/scanner
assets; pinning its caller is not a complete freeze of those assets.
GitHub Actions Workflows¶
| Workflow | Trigger | Description |
|---|---|---|
ci.yml |
PRs, main, weekly, manual | Minimum PR smoke; main/weekly coverage and representative integration |
integration-full.yml |
Manual dispatch, called by publish-pypi.yml |
Full Python × CUBRID compatibility matrix |
release-please.yml |
Push main or manual dispatch | Open/update PR-only release candidate (version + generated/curated notes) |
publish-pypi.yml |
Push to main, recovery dispatch | Detect a merged release PR, then full matrix, build, tag + GitHub Release + PyPI, cookbook verification |
CI Matrix¶
Routine CI uses representative offline and live combinations rather than the full matrix. Ordinary PRs run offline smoke tests on Ubuntu/Python 3.12. High-risk PRs run the full existing offline regressions without coverage on the oldest and newest supported Python (3.11 and 3.15) and select Python 3.14/CUBRID 11.4 integration. main, changed weekly runs and manual dispatches run the full offline suite with 95% coverage on Python 3.11 and 3.15 and add Python 3.11/CUBRID 10.2 integration. Repository tooling is path-selected on one Linux lane. Full integration is explicit/manual and release-only. See CI execution policy for exact selection and validation requirements.
CI live jobs and the Make contract¶
Contributors run live tests with make integration (the root docker-compose.yml,
one CUBRID server, -m "integration and not tls") and make integration-tls.
A CI job that talks to a live CUBRID either runs one of these targets or carries a
# Not using make integration because ... comment in the workflow, with a row in the
table below; tests/test_workflow_make_contract.py enforces both (#1023). A job is
switched only when the switch keeps its CUBRID versions and matrix, pytest selection,
lane audit, result files and artifacts, readiness and permissions. No job meets that
today, so every live job keeps its own service containers and steps:
| Workflow | Job | Decision | Reason the Make contract is not equivalent |
|---|---|---|---|
ci.yml |
integration-tests |
keep | Selects integration and not slow and not tls; make integration also runs the slow tests. Runs the pre-run lane audit, a Python × CUBRID matrix, and a GitHub service health check before the steps |
integration-full.yml |
integration-full |
keep | Same selection and audit as integration-tests, over the 5 Python × 4 CUBRID release matrix at the requested SHA |
ci.yml |
integration-tls |
keep | Provisions an SSL=ON broker with a generated certificate and the SSL=OFF port 30000, and runs integration and tls across tests/; the Compose service has no SSL broker and make integration-tls runs only tests/test_aio_ssl_integration.py |
integration-full.yml |
integration-tls |
keep | Same as the ci.yml TLS job, over the full TLS matrix |
ci.yml |
integration-charset |
keep | Needs an EUC-KR database (CUBRID_LOCALE=ko_KR.euckr), which the Compose service does not set; runs only tests/test_integration_charset.py |
integration-full.yml |
integration-charset |
keep | Same as the ci.yml charset job |
ci.yml |
official-differential |
keep | Needs CUBRID 10.2 and 11.4 at the same time (ports 33102, 33114) and the pinned official oracle build; Compose starts one server |
integration-full.yml |
official-differential |
keep | Same as the ci.yml official differential |
integration-full.yml |
version-differential |
keep | Needs CUBRID 10.2, 11.0, 11.2 and 11.4 at the same time (CUBRID_VERSION_MATRIX) |
bug-hunt.yml |
property-and-fault |
keep | Weekly wide Hypothesis profile, separate slow soak/chaos lane, xunit1 JUnit files and repro collection with server info |
bug-hunt.yml |
perf-trend |
keep | Runs tests/test_benchmarks.py against a cached baseline, not the integration marker |
bug-hunt.yml |
downstream-corpus |
keep | Runs the downstream repositories' own suites against the driver at the exact SHA |
The publish-pypi.yml jobs that verify a release run no CUBRID themselves: matrix
calls integration-full.yml, covered above, and verify-candidate and
verify-cookbook call the cookbook's smoke-test.yml.
PR verification cost¶
Current CI cost evidence lives in CI execution policy: runner minutes, job counts and failure yield per event tier, with reproducible definitions, plus the coverage-ownership table (#750, #781). Use those numbers, not older ones, when you reason about CI cost.
The earlier #564 baseline (2026-10-01) measured a workflow that no longer exists: a 2 OS × 5 Python offline matrix including Python 3.10, with pip caching. It is kept in #564 and in this file's git history. Two of its outcomes still apply:
scripts/wait_for_cubrid.pyselects code-gated integration (tests/test_workflow_path_impact.py).- A repository-tooling test runs the
CI Gateagainst synthetic failed and cancelled results.
Dependency installation has since moved to uv (Dependency installation).
Documentation Structure¶
The site serves three readers, and each page has one of these roles:
- Learning path (Start here): a newcomer learns one operation per lesson, in order, with one continuing example.
- User reference (Usage, Reference): an experienced user looks up an option, an API contract, a type or a support limit.
- Help (Help): a user with a problem finds troubleshooting, the FAQ and the existing bug and vulnerability reporting routes.
- Development material (Development and contributing): a contributor finds protocol, design and verification evidence.
This map records each affected page's reader, the question it answers, what
comes before and after it, and the decision for it. "Source" is the Markdown
file you edit; the site URL is relative to the site root, and docs/ko/<page>
is served at ko/<page>/ with the same structure. docs/llms-full.txt and the
root llms.txt are generated by scripts/generate_llms_full.py: never edit
them by hand.
| Source and site URL | Reader and question | Before it | After it | Decision |
|---|---|---|---|---|
docs/index.md, / |
Anyone: what is pycubrid and where do I start? | Nothing | quickstart.md |
Keep. |
README.md (repository and PyPI page, not built into the site) |
A visitor: what is this project? | Nothing | quickstart.md |
Keep; its links to the start page stay valid. |
docs/quickstart.md, /quickstart/ |
Newcomer: how do I get a database and Python ready? | Python 3.11 or later, and Docker or an existing database | first-query.md |
Keep as the single canonical start (lesson 1, preparation). Steps 2 and 3 and Clean up are short landings that link to the lessons; every existing heading stays, so old deep links still land. The first-connection error table stays here. |
docs/first-query.md, /first-query/ |
Newcomer: how do I connect, execute one statement and read one row? | quickstart.md |
save-a-row.md |
Lesson 2. |
docs/save-a-row.md, /save-a-row/ |
Newcomer: how do I create a table, save one row and see it from another connection? | first-query.md |
commit-and-rollback.md |
Lesson 3, split from quickstart Step 3. |
docs/commit-and-rollback.md, /commit-and-rollback/ |
Newcomer: what does a transaction keep and discard? | save-a-row.md |
finish-safely.md |
Lesson 4, split from quickstart Step 3. |
docs/finish-safely.md, /finish-safely/ |
Newcomer: how do I remove what I created, and recover from an interrupted or repeated run? | commit-and-rollback.md |
EXAMPLES.md, CONNECTION.md, Help |
Lesson 5, split from quickstart Clean up, with the same table ownership check and warnings. |
docs/EXAMPLES.md, /EXAMPLES/ |
User: how do I do a common task? | The lessons | The reference pages | Keep in Usage. |
docs/CONNECTION.md, /CONNECTION/ |
User: which connection options, URLs, timeouts and TLS settings exist? | The lessons | API_REFERENCE.md |
Keep the file and its anchors; the lessons link its sections. Reference, labelled Connection Options. |
docs/PARAMETER_BINDING.md, /PARAMETER_BINDING/ |
User: how are values passed safely, and what is not guaranteed? | The lessons | TYPES.md |
Keep; Reference, labelled as passing values safely. |
docs/TYPES.md, docs/API_REFERENCE.md, docs/SUPPORT_MATRIX.md |
User: which type, call or version is supported? | Any page | Any page | Keep in Reference. |
docs/CAS_OVERVIEW.md, /CAS_OVERVIEW/ |
Experienced user: what are the Broker, a CAS process, the CAS wire protocol and CCI, and where do DB-API calls meet the protocol? | The reference pages | PROTOCOL.md, ARCHITECTURE.md |
Reference, labelled Advanced: a concept overview that links to PROTOCOL.md, ARCHITECTURE.md and CONNECTION.md instead of copying them. |
docs/TROUBLESHOOTING.md, docs/faq.md |
User with a problem: what does this error mean? | Any page | The reporting instructions in SECURITY.md and the issue forms |
Keep the files; Help, with links to the issue forms and SECURITY.md. |
docs/PROTOCOL.md, docs/ARCHITECTURE.md, docs/UPSTREAM_TEST_LEDGER.md, docs/UPSTREAM_COMPATIBILITY.md, docs/PREPARED_BINDING_DESIGN.md |
Contributor: how does the driver work, and what evidence backs it? | DEVELOPMENT.md |
Any page | Keep the files; Development and contributing in navigation (moved from Reference). |
docs/DEVELOPMENT.md, docs/CI_POLICY.md, docs/PRD.md, docs/PERFORMANCE.md |
Contributor or operator: how do I build, test, measure and release? | Any page | Any page | Keep; Development and contributing, except Performance, which stays in Operations. |
Writing a Lesson¶
A lesson under Start here teaches one operation. Give each lesson these parts, in this order, and keep the Korean page in the same structure:
- Goal: one or two sentences on what the reader does, and which lesson it follows.
- Before you start: what earlier lessons must have produced (a printed result, a table, an active virtual environment). Do not assume an open Python session or a previous notebook: every program is a file run from the terminal.
- Program or command: the file name, the complete source, and the command that runs it.
- Expected result: the exact output, verified on a disposable CUBRID server.
- Explanation: what each new call did, with links to the reference section that has the full contract. Put the timeout, retry or security limitation that applies to the operation here, not only in the reference.
- Next step: a link to the next lesson.
Recovery for a repeated or interrupted run goes in the lesson that creates the resource or in Finish Safely. Cleanup must stay limited to what the lessons created, with the ownership check visible in the program. When a program or command changes, rerun the whole sequence on a disposable server and record the platform it ran on.
Architecture Overview¶
graph TD
user[User Code] --> init[__init__.py - Module API connect/types/exceptions]
init --> connection[connection.py - TCP socket, CAS handshake, session]
connection --> cursor[cursor.py - SQL execution, parameter binding, fetch]
cursor --> protocol[protocol.py - CAS packet classes serialize/deserialize]
protocol --> packet[packet.py - PacketWriter + PacketReader binary I/O]
packet --> constants[constants.py - CAS function codes, data types, enums]
types[types.py - PEP 249 types] --> cursor
exceptions[exceptions.py - PEP 249 errors] --> connection
lob[lob.py - LOB objects] --> connection
Data Flow¶
- User calls
cursor.execute("SELECT ...") - Cursor binds parameters, creates
PrepareAndExecutePacket - Connection calls
_send_and_receive(packet) - PacketWriter serializes the request with protocol header
- Socket sends bytes to CAS server
- Socket receives response bytes
- PacketReader deserializes the response
- Packet parses column metadata and row data
- Cursor stores rows for
fetchone()/fetchall()
Adding a New Packet Type¶
To add a new CAS function:
- Add the function code to
CASFunctionCodeinconstants.py:
- Create the packet class in
protocol.py:
class MyNewPacket:
"""Description (FC=42)."""
def __init__(self, arg1: int) -> None:
self.arg1 = arg1
self.result: str = ""
def write(self, cas_info: bytes) -> bytes:
writer = PacketWriter()
writer._write_byte(CASFunctionCode.MY_NEW_FUNCTION)
writer.add_int(self.arg1)
payload = writer.to_bytes()
header = build_protocol_header(len(payload), cas_info)
return header + payload
def parse(self, data: bytes) -> None:
reader = PacketReader(data)
_ = reader._parse_bytes(DataSize.CAS_INFO)
response_code = reader._parse_int()
if response_code < 0:
remaining = len(data) - 8
_raise_error(reader, remaining)
# Parse result-specific data
self.result = reader._parse_null_terminated_string(response_code)
- Add tests in
tests/test_protocol.py:
def test_my_new_packet_write():
packet = MyNewPacket(arg1=42)
data = packet.write(b"\x00\x00\x00\x00")
assert len(data) > 8 # Header + payload
Adding a New Data Type¶
To support a new CUBRID data type:
- Add the type code to
CUBRIDDataTypeinconstants.py - Add the reader in
_read_value()inprotocol.py - Add the writer in
PacketWriterinpacket.py(if needed) - Add tests for both reading and writing
Release Process¶
Releases are maintainer-only and follow RELEASING.md:
release-please.yml opens a release PR (version bump + dated CHANGELOG section, checked
with make release-check VERSION=X.Y.Z); after review and squash-merge, publish-pypi.yml
runs the full matrix, builds once, tags, publishes to PyPI and verifies the cookbook
automatically. Nobody pushes tags or publishes by hand.
Changelog validation¶
python scripts/lint_changelog.py rejects repeated subsection headings within a release, including an Unreleased-only file, but like the section policy only in [Unreleased] and releases after 1.10.0: released history up to 1.10.0 is never rewritten. Different releases may reuse the same subsection names. Fenced code blocks follow one rule in scripts/lint_changelog.py and scripts/compose_release_changelog.py: a fenced ### line is entry content, never a heading; a fenced ## [ release header is an error (scripts/extract_release_notes.py is not fence-aware and would truncate the Release body); an unclosed fence is an error. Only fences that start at column 0 with three backticks are recognised, not tilde or indented/nested fences. The one exception is the composer's rewrite of generated issue references (, closes [#N] to , refs [#N]) in CHANGELOG.md and the release PR body: it skips every fence GitHub renders as code, including tilde fences, fences indented by up to three spaces and longer fences, because it only decides where not to rewrite and the PR body is not linted. Both scripts are shared with sqlalchemy-cubrid, cubrid-mcp-server and the cookbook; once all four repositories adopt the rule-5 cutoff gating, only SECTION_POLICY_CUTOFF differs.
In [Unreleased] and in releases after 1.10.0 it also requires the standard ###
sections, each with content, in this order: Upgrade notes, Added, Changed, Deprecated,
Removed, Fixed, Security, Performance, Documentation, CI, Tests. Use Documentation, not
Docs, and file release automation under CI or Changed. Releases up to 1.10.0 keep
their historical headings. The GitHub Release body is the CHANGELOG section plus one
**Full Changelog** compare link; see the "GitHub Release Policy" in AGENTS.md.