Skip to content

Development Guide

Everything you need to set up, test, and contribute to pycubrid.


Table of Contents


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.:

pytestmark = [pytest.mark.integration, pytest.mark.no_escape_pin]

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.

pytest tests/test_replay_parity.py -v

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:

pip install -e . --group dev --group mutation
make mutation          # mutmut run && mutmut results

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:

  1. the per-field variables CUBRID_TEST_HOST, CUBRID_TEST_PORT, CUBRID_TEST_DB, CUBRID_TEST_USER, CUBRID_TEST_PASSWORD win;
  2. otherwise the matching component of CUBRID_TEST_URL=cubrid://user[:password]@host[:port]/database;
  3. 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:

pytest tests/ -m "integration and tls" -v
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:

  1. Starts a CUBRID 11.4 container manually (so the broker config can be patched after the container is up).
  2. Generates a fresh self-signed certificate (CN=localhost, SAN=DNS:localhost), injects it into the container as cas_ssl_cert.{crt,key}, then flips SSL=OFF → SSL=ON for BROKER1 and restarts the broker so the new cert is picked up.
  3. Exports the generated CA bundle to the Python test fixture via CUBRID_TLS_TEST_CA_FILE and SSL_CERT_FILE.
  4. Probes the broker with a real TLS handshake and fails the job loudly if TLS is not actually serving — silent skips are explicitly rejected.
  5. Runs every integration and tls test (tests/test_aio_ssl_integration.py and tests/test_tls_matrix_integration.py) against the TLS broker with the CUBRID_TLS_TEST_* env vars wired up automatically, including CUBRID_TLS_TEST_PLAIN_PORT=30000 for the container's SSL=OFF query_editor broker.

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 cubrid service 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

[tool.ruff]
line-length = 100
target-version = "py311"

Conventions

  • Imports: from __future__ import annotations in every module
  • Type hints: Full typing; PEP 561 compliant (py.typed)
  • super(): Always super().__init__(), never super(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) — use super() only
  • No Python 2 constructs
  • No empty except blocks (except in cleanup paths like close())
  • No type suppression (# type: ignore without 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:

SELECT IF(1=0, SET{1}, 0.000);

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.py selects code-gated integration (tests/test_workflow_path_impact.py).
  • A repository-tooling test runs the CI Gate against 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:

  1. Goal: one or two sentences on what the reader does, and which lesson it follows.
  2. 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.
  3. Program or command: the file name, the complete source, and the command that runs it.
  4. Expected result: the exact output, verified on a disposable CUBRID server.
  5. 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.
  6. 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

  1. User calls cursor.execute("SELECT ...")
  2. Cursor binds parameters, creates PrepareAndExecutePacket
  3. Connection calls _send_and_receive(packet)
  4. PacketWriter serializes the request with protocol header
  5. Socket sends bytes to CAS server
  6. Socket receives response bytes
  7. PacketReader deserializes the response
  8. Packet parses column metadata and row data
  9. Cursor stores rows for fetchone()/fetchall()

Adding a New Packet Type

To add a new CAS function:

  1. Add the function code to CASFunctionCode in constants.py:
class CASFunctionCode(IntEnum):
    # ... existing codes ...
    MY_NEW_FUNCTION = 42
  1. 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)
  1. 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:

  1. Add the type code to CUBRIDDataType in constants.py
  2. Add the reader in _read_value() in protocol.py
  3. Add the writer in PacketWriter in packet.py (if needed)
  4. 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.