From 8d87fad4c263288e83cc65acb258705ee7e22a1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 01:35:03 +0000 Subject: [PATCH 1/6] Adopt TapHouse rules and the tap::python namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the renamed PythonTap (was Python-Max) under the shared Tap House Rules and the family C++ namespace convention. - Sync the four canonical TapHouse configs (.clang-format, .clang-tidy, STYLE.md, .pre-commit-config.yaml) at v4, and add scripts/tidy.sh (the local clang-tidy gate mirror). - Namespace: tap::python_runtime -> tap::python (the runtime alias and its uses are unchanged; the Min external class `python` stays global, per the family's Min-object convention). - Apply the house layout with clang-format, and add braces to every control-flow body (the mandatory-braces rule; clang-format does not insert them, so this was a one-time InsertBraces pass, verified complete). Naming already matched the house scheme (m_ members, k_/s_ prefixes, snake_case). - Add .github/workflows/style.yml: the TapHouse drift check (v4) and a clang-format gate on Linux, plus the clang-tidy naming + braces gate on macOS — tap.python~ only configures with the embedded Python runtime, which installs on macOS/Windows only, so Linux cannot produce the compile database. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRTDgYKY38WH8Bqz17W9k1 --- .clang-format | 74 ++++ .clang-tidy | 132 +++++++ .github/workflows/style.yml | 49 +++ .pre-commit-config.yaml | 23 ++ STYLE.md | 122 +++++++ scripts/tidy.sh | 77 ++++ .../tap.python_tilde/tap.python_tilde.cpp | 30 +- .../tap.python_tilde/tap.python_tilde.h | 292 ++++++++------- .../tap.python_tilde_attribute.h | 40 ++- .../tap.python_tilde/tap.python_tilde_cglue.h | 6 +- .../tap.python_tilde_message.h | 67 ++-- .../tap.python_tilde_runtime.h | 340 +++++++++--------- .../tap.python_tilde_test.cpp | 31 +- 13 files changed, 896 insertions(+), 387 deletions(-) create mode 100644 .clang-format create mode 100644 .clang-tidy create mode 100644 .github/workflows/style.yml create mode 100644 .pre-commit-config.yaml create mode 100644 STYLE.md create mode 100755 scripts/tidy.sh diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..aedca32 --- /dev/null +++ b/.clang-format @@ -0,0 +1,74 @@ +# Tap House Rules — the Tap family house style. Copy verbatim into every *Tap repo. +# 4-space indent (incl. namespaces), aligned declaration/assignment columns, +# attached braces (else/catch break), comma-first ctor initializers, +# left-bound pointers, 120-column limit. Layout only — naming and mandatory +# braces are enforced separately by .clang-tidy (clang-format cannot check +# identifier names, and its brace insertion is not semantically aware). +Language: Cpp +BasedOnStyle: LLVM +Standard: c++20 + +ColumnLimit: 120 +IndentWidth: 4 +AccessModifierOffset: -2 +NamespaceIndentation: All + +PointerAlignment: Left +DerivePointerAlignment: false +BreakBeforeBinaryOperators: NonAssignment +SpaceBeforeCpp11BracedList: false +AlwaysBreakTemplateDeclarations: Yes + +# Braces attach everywhere (including functions); only else/catch break. +BreakBeforeBraces: Custom +BraceWrapping: + AfterFunction: false + AfterClass: false + AfterStruct: false + AfterNamespace: false + AfterControlStatement: Never + BeforeElse: true + BeforeCatch: true +BreakConstructorInitializers: BeforeComma +PackConstructorInitializers: Never + +AlignConsecutiveAssignments: true +AlignConsecutiveDeclarations: true +AlignTrailingComments: true + +# Short accessor functions and lambdas may stay inline, but control-flow +# statements never do: every if/for/while is braced AND expanded (see +# .clang-tidy readability-braces-around-statements). +AllowShortFunctionsOnASingleLine: Inline +AllowShortLambdasOnASingleLine: All +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: Never + +BreakStringLiterals: false +KeepEmptyLinesAtTheStartOfBlocks: false +InsertNewlineAtEOF: true + +# Include ordering: main header (auto, priority 0) -> C++ standard -> +# third-party -> this project. Regroup enforces it; blank lines between groups. +SortIncludes: CaseSensitive +IncludeBlocks: Regroup +IncludeCategories: + # C++ standard library: with no '/' and no '.' (e.g. ) + - Regex: '^<[[:alnum:]_]+>$' + Priority: 2 + # Other angle-bracket headers (third-party, e.g. ) + - Regex: '^<.*>$' + Priority: 3 + # This project: quoted includes + - Regex: '^".*"$' + Priority: 4 + +# Min-DevKit declarative DSL (Max/Min externals: TapTools, AmbiTap-Max, ...). +# MIN_FUNCTION / MIN_ARGUMENT_FUNCTION expand to a lambda; teach clang-format +# their shape so attribute/message/argument setter bodies format as lambda +# blocks instead of being shredded. Completely inert for repos that don't use +# these macros (the pure-C++ libraries). Requires clang-format >= 15. +Macros: + - 'MIN_FUNCTION=[](const atoms& args, int inlet) -> atoms' + - 'MIN_ARGUMENT_FUNCTION=[](const atom& arg, int index) -> void' diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..bb253e0 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,132 @@ +# Tap House Rules — naming + mandatory-braces enforcement. Copy verbatim into +# every *Tap repo. This is what actually checks m_ members, k_ constants, snake_case +# types/functions, PascalCase template parameters, and braces around every +# control-flow body — clang-format cannot (and its InsertBraces is not +# semantically aware). Scope is intentionally limited to these for now; +# correctness/modernize checks can be layered on later. +# +# NOTE: WarningsAsErrors is intentionally NOT set here so local runs only warn. +# CI passes --warnings-as-errors=readability-* to make the gate blocking. +Checks: > + -*, + readability-identifier-naming, + readability-braces-around-statements +# Analyze this project's own headers only (under include/); vendored third_party +# and fetched deps live outside include/ and are excluded. Generated tables +# (room_data.h, hrtf_data.h, tdesigns.h) live under include/ but carry +# // NOLINTBEGIN(readability-identifier-naming) markers from their generators. +# NOTE: clang-tidy uses llvm::Regex, which has NO negative lookahead — a +# '^(?!...)' pattern silently matches nothing and disables the check. +HeaderFilterRegex: '.*/(include|tests)/.*' + +# --- Linear-algebra notation carve-out -------------------------------------- +# The DSP math deliberately uses capitalized matrix/vector symbols (Y = SH +# matrix, D = decoder, R = rotation, ...). Permit a leading-capital symbol with +# an optional short subscript and _snake suffixes (Y, Yd, R9, Y_virtual). This +# Also matrix products (DtD, YtD). Still rejects camelCase (frameCount). +# Applied below per category via IgnoredRegexp. +CheckOptions: + # --- Types: snake_case --- + - key: readability-identifier-naming.ClassCase + value: lower_case + - key: readability-identifier-naming.StructCase + value: lower_case + - key: readability-identifier-naming.UnionCase + value: lower_case + - key: readability-identifier-naming.EnumCase + value: lower_case + - key: readability-identifier-naming.EnumConstantCase + value: lower_case + - key: readability-identifier-naming.ScopedEnumConstantCase + value: lower_case + - key: readability-identifier-naming.TypeAliasCase + value: lower_case + - key: readability-identifier-naming.TypedefCase + value: lower_case + - key: readability-identifier-naming.NamespaceCase + value: lower_case + + # --- Concepts: snake_case (like the types they constrain, per P1754) --- + - key: readability-identifier-naming.ConceptCase + value: lower_case + + # --- Functions / methods: snake_case --- + - key: readability-identifier-naming.FunctionCase + value: lower_case + - key: readability-identifier-naming.MethodCase + value: lower_case + + # --- Variables / parameters / locals: snake_case, no prefix --- + - key: readability-identifier-naming.VariableCase + value: lower_case + - key: readability-identifier-naming.ParameterCase + value: lower_case + - key: readability-identifier-naming.LocalVariableCase + value: lower_case + - key: readability-identifier-naming.LocalConstantCase + value: lower_case + # Math-notation carve-out (see header): capitalized matrix/vector symbols. + - key: readability-identifier-naming.ParameterIgnoredRegexp + value: '^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)*$' + - key: readability-identifier-naming.LocalVariableIgnoredRegexp + value: '^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)*$' + - key: readability-identifier-naming.LocalConstantIgnoredRegexp + value: '^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)*$' + - key: readability-identifier-naming.VariableIgnoredRegexp + value: '^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)*$' + + # --- Data members: private/protected get m_; public struct fields bare --- + - key: readability-identifier-naming.PrivateMemberCase + value: lower_case + - key: readability-identifier-naming.PrivateMemberPrefix + value: 'm_' + - key: readability-identifier-naming.ProtectedMemberCase + value: lower_case + - key: readability-identifier-naming.ProtectedMemberPrefix + value: 'm_' + - key: readability-identifier-naming.PublicMemberCase + value: lower_case + # const (non-static) data members are still members -> keep the m_ marker + - key: readability-identifier-naming.ConstantMemberCase + value: lower_case + - key: readability-identifier-naming.ConstantMemberPrefix + value: 'm_' + # Math-notation carve-out for capitalized matrix/vector member symbols. + - key: readability-identifier-naming.PublicMemberIgnoredRegexp + value: '^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)*$' + - key: readability-identifier-naming.PrivateMemberIgnoredRegexp + value: '^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)*$' + - key: readability-identifier-naming.ProtectedMemberIgnoredRegexp + value: '^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)*$' + + # --- Constants at namespace/class/static scope: k_ + snake_case --- + # (constexpr/const LOCALS stay bare via LocalConstantCase above) + - key: readability-identifier-naming.GlobalConstantCase + value: lower_case + - key: readability-identifier-naming.GlobalConstantPrefix + value: 'k_' + - key: readability-identifier-naming.ClassConstantCase + value: lower_case + - key: readability-identifier-naming.ClassConstantPrefix + value: 'k_' + - key: readability-identifier-naming.StaticConstantCase + value: lower_case + - key: readability-identifier-naming.StaticConstantPrefix + value: 'k_' + + # --- Template parameters: PascalCase (the ONLY leading-capital names) --- + # Applies to type AND non-type params: template , not . + - key: readability-identifier-naming.TemplateParameterCase + value: CamelCase + - key: readability-identifier-naming.TypeTemplateParameterCase + value: CamelCase + - key: readability-identifier-naming.ValueTemplateParameterCase + value: CamelCase + + # --- Macros: ALL_CAPS --- + - key: readability-identifier-naming.MacroDefinitionCase + value: UPPER_CASE + + # --- Mandatory braces: brace every control-flow body, even one-liners --- + - key: readability-braces-around-statements.ShortStatementLines + value: '0' diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 0000000..cb2ef8f --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,49 @@ +name: Tap House Style + +# Enforces the shared Tap House Rules: (1) a drift check against the canonical +# TapHouse configs, (2) a clang-format layout check, and (3) clang-tidy naming + +# mandatory-braces over the external's own translation units. +# +# tap.python~ only configures with the embedded Python runtime present, and that +# runtime installs on macOS/Windows only — so the clang-tidy gate (which needs a +# compile database) runs on macOS. The drift and clang-format checks are +# platform-independent and stay on Linux. +on: [push, pull_request] + +jobs: + drift: + uses: tap/taphouse/.github/workflows/drift-check.yml@v4 + with: + ref: v4 + + clang-format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install clang-format + run: sudo apt-get update -q && sudo apt-get install -y -q clang-format-18 + - name: clang-format check (own sources) + run: clang-format-18 --dry-run --Werror $(git ls-files 'source/projects/*.cpp' 'source/projects/*.h') + + clang-tidy: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Install the embedded Python runtime (needed to configure) + run: ./scripts/install-runtime.sh + - name: Install clang-tidy (LLVM 18) + run: brew install llvm@18 + - name: Configure (compile database) + run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + - name: clang-tidy (project TUs; min-api and fetched deps excluded) + run: | + tidy="$(brew --prefix llvm@18)/bin/clang-tidy" + files=$(python3 -c "import json; print('\n'.join(e['file'] for e in json.load(open('build/compile_commands.json')) if 'source/projects' in e['file']))") + fail=0 + for f in $files; do + out=$("$tidy" -p build --warnings-as-errors='readability-*' "$f" 2>/dev/null || true) + if echo "$out" | grep -qE "warning:|error:"; then echo "$out"; fail=1; fi + done + [ "$fail" -eq 0 ] && echo "clang-tidy clean." || { echo "::error::clang-tidy found violations"; exit 1; } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b392057 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +# Canonical Tap House pre-commit config — the single source of truth for the +# Tap family's local formatting hook. Distributed to every Tap repo by +# scripts/sync.sh (alongside .clang-format / .clang-tidy / STYLE.md) and kept +# honest by the drift-check workflow, so every repo runs the SAME hook at the +# SAME pinned clang-format version. +# +# Why the pin matters: an ad-hoc hook using each machine's own clang-format +# would format differently than CI and be worse than none. The `rev` below is +# the Tap-wide clang-format version — bump it HERE, re-sync, and every repo +# (and its CI, which runs `pre-commit run --all-files`) moves together. +# +# Adopt in a consumer repo: +# 1. taphouse/scripts/sync.sh /path/to/your-repo # copies this file in +# 2. cd your-repo && pre-commit install # once per clone +# Thereafter `git commit` formats staged C/C++ before it can be pushed, so the +# clang-format CI gate can never fail on a local commit again. +repos: + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v18.1.3 # Tap-wide clang-format version (matches CI) + hooks: + - id: clang-format + types_or: [c, c++] + exclude: '^third_party/' # vendored sources are formatted upstream, never by us diff --git a/STYLE.md b/STYLE.md new file mode 100644 index 0000000..ebfcf54 --- /dev/null +++ b/STYLE.md @@ -0,0 +1,122 @@ +# Tap House Rules + +> *The Tap house style — always on tap.* + +The shared house style for the Tap libraries (AmbiTap, SampleRateTap, OscTap, +and future `*Tap` libraries). Anchored to the C++ standard library's own +conventions (per the ISO C++ Core Guidelines "NL" section), with a small set +of deliberate, documented exceptions. + +Two config files enforce this and must be copied verbatim into every repo: + +- **`.clang-format`** — layout (whitespace, braces, alignment, includes). +- **`.clang-tidy`** — identifier naming (`readability-identifier-naming`). + clang-format *cannot* check names; this is what does. + +CI runs `clang-format --dry-run --Werror` and `clang-tidy` so drift can't +return. + +--- + +## 1. Naming + +| Kind | Convention | Example | +|------|-----------|---------| +| Types (class/struct/enum/alias) | `snake_case` | `encoder`, `spsc_ring` | +| Functions / methods | `snake_case` | `push`, `write_available` | +| Variables / parameters / locals | `snake_case` | `frame_count`, `min_capacity` | +| Concepts | `snake_case` (like types) | `sample_type` | +| Template parameters | `PascalCase` — the ONLY leading-capital names | `T`, `S`, `Sample`, `Allocator` | +| Private/protected data members | `m_` + `snake_case` | `m_channels`, `m_order` | +| Public data members (struct fields) | `snake_case`, no prefix | `sample_rate_hz` | +| Constants (namespace/class/static) | `k_` + `snake_case` | `k_smoothing_samples`, `k_cache_line` | +| Enumerators | `snake_case` | `state::filling` | +| Macros | `ALL_CAPS` | `SRT_VERSION_MAJOR`, `TAP_EXPECTS` | + +A leading capital letter means **template parameter** and nothing else. This +is the standard library's own allocation (`CharT`, `Rep`, `Period`, +`Allocator`) and is why concepts are lower-case: they read in type position, +so they look like the types they constrain. + +**Deliberate deviations from strict std:** +- `k_` prefix on constants (std uses bare `snake_case`) — kept for use-site + clarity. Applies to namespace-, class-, and static-scope constants; + `constexpr` *locals* stay bare. +- `m_` prefix on encapsulated data members (std reserves `_`; user code has no + standard convention here) — kept for self-documentation and greppability. + +**Parameters take no prefix** (no `a_`/`an_`). `m_` already prevents any +member/parameter collision, and prefixes would clutter the public signatures +that *are* the library's contract. Lean on `const` and small functions for +input/local clarity. + +**Repo exception — OscTap (drop-in legacy continuation).** OscTap continues +[oscpack](http://www.rossbencina.com/code/oscpack) as a *drop-in +source-compatible* successor: its public API keeps oscpack's original +identifiers — PascalCase types and methods (`ReceivedMessage`, +`OutboundPacketStream`, `BeginBundle()`, `AsFloat()`) and trailing-underscore +data members (`size_`, `value_`). Renaming these to the house `snake_case`/`m_` +scheme would break the source compatibility that is the library's reason to +exist. OscTap therefore adopts the **layout** rules (`.clang-format`) in full +but is **exempt from the naming rules** (`readability-identifier-naming`): it +ships a local `.clang-tidy` that disables that check while keeping mandatory +braces, and its CI runs a format-only style gate instead of the shared +`drift-check.yml`. The exemption is specific to legacy-continuation repos; +greenfield `*Tap` code follows the naming rules above. + +## 2. Layout + +- **Indent:** 4 spaces, including inside namespaces. +- **Braces:** attached everywhere (functions included); only `else` and + `catch` break onto their own line. Every control-flow body is braced *and + expanded* onto its own lines — no single-line `if`/`for`/`while`, even for + guard clauses (braces via clang-tidy `readability-braces-around-statements`; + expansion via `AllowShortBlocksOnASingleLine: Never`). Short accessor + functions and lambdas may still be inline. +- **Brace-init spacing:** no space before a braced-init list — `float x{0.0f}`, + not `x {0.0f}`. +- **Column alignment:** consecutive declarations, assignments, and trailing + comments are aligned. +- **Constructor initializers:** comma-first, one per line, never packed. +- **Pointers/references:** bound to the type — `const float* p`, `T& r`. +- **Member declaration order (per NL.16):** `public` -> `protected` -> + `private`; within a class: types/aliases -> constructors/assignment/ + destructor -> functions -> data members last. +- **Column limit:** 120. +- **`const` placement:** west-const (`const T`, not `T const`) — enforced by + review, not tooling. + +## 3. Files + +- **Extension:** `.h` for headers (family-wide). +- **Header guard:** `#pragma once` (first line after the banner). Universally + supported by GCC/Clang/MSVC; replaces the `#ifndef`/`#define`/`#endif` triple. +- **Per-file banner:** three lines — + ```cpp + /// @file spsc_ring.h + /// @brief Lock-free single-producer single-consumer ring buffer. + // SPDX-License-Identifier: MIT + // Copyright 2025-2026 Timothy Place. + ``` +- **Doc comments:** `///` triple-slash with `@`-style commands + (`@param`, `@return`, `@throws`, `@pre`). Not the `\`-command dialect. +- **Include ordering:** (1) the file's own corresponding header (in a `.cpp`), + (2) C++ standard headers, (3) third-party, (4) this project — enforced by + clang-format `IncludeBlocks: Regroup`, blank line between groups. + +## 4. Safety idioms + +The Tap libraries are header-only, zero-dependency, and target real-time / +embedded use (some build `-fno-exceptions`). We adopt the *vocabulary* of the +GSL but take **no dependency on it**; the helpers below are freestanding. + +- **Contracts:** `TAP_EXPECTS(cond)` / `TAP_ENSURES(cond)` — assert in debug, + clamp or no-op in release, **never throw**. (Generalizes AmbiTap's + `validate.h`.) Not `gsl::Expects` (terminates + adds a dependency). +- **Narrowing:** `narrow_cast(x)` — a documented `static_cast` synonym for + intentional lossy conversions (e.g. Q15/Q31 fixed-point). Not `gsl::narrow` + (throws; unusable under `-fno-exceptions`). +- **Views:** `std::span` (C++20, freestanding-friendly). Never `gsl::span`. +- **Non-null / ownership / bounds:** expressed via `@pre` documentation and + debug asserts, **not** wrapper types. Raw pointers and raw indexing stay in + hot paths for performance; `not_null`/`owner`/`at()` are not used as types. diff --git a/scripts/tidy.sh b/scripts/tidy.sh new file mode 100755 index 0000000..45fc009 --- /dev/null +++ b/scripts/tidy.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Local mirror of the CI clang-tidy gate (.github/workflows/style.yml): the +# TapHouse .clang-tidy naming + mandatory-braces checks over this project's +# own translation units. Run it before pushing. +# +# WHY THIS EXISTS (and is not a pre-commit hook): the pre-commit hook only +# runs clang-FORMAT, which is cheap and context-free. clang-TIDY needs a +# compile database (a configured CMake build) and compiles every TU to reason +# about types — too slow and stateful for a per-commit hook, so CI keeps it as +# its own gate. This script is the fast local equivalent of that gate. +# +# Usage: +# scripts/tidy.sh # sweep every project TU (full CI mirror) +# scripts/tidy.sh tests/test_foo.cpp … # only the given TU(s) — fast, for a change +# +# Env: +# CLANG_TIDY=clang-tidy-18 # binary to use (default: clang-tidy-18, then clang-tidy) +# TIDY_BUILD=build-tidy # compile-database build dir (kept separate from ./build) +# TIDY_RECONFIGURE=1 # force a cmake re-configure of the compile database +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +# Prefer clang-tidy-18 — the version the CI gate installs and the .clang-tidy +# checks are validated against; a different major version may disagree. +tidy="${CLANG_TIDY:-}" +if [ -z "$tidy" ]; then + if command -v clang-tidy-18 >/dev/null 2>&1; then + tidy=clang-tidy-18 + elif command -v clang-tidy >/dev/null 2>&1; then + tidy=clang-tidy + echo "warning: clang-tidy-18 not found; using '$tidy'. CI pins v18 — results may differ." >&2 + else + echo "error: no clang-tidy found. Install clang-tidy-18 (apt) or set CLANG_TIDY=..." >&2 + exit 127 + fi +fi + +build="${TIDY_BUILD:-build-tidy}" + +# A compile database is what clang-tidy needs; reuse it across runs unless it is +# missing or a reconfigure is forced. This does not touch your ./build dir. +if [ "${TIDY_RECONFIGURE:-0}" = "1" ] || [ ! -f "$build/compile_commands.json" ]; then + echo "== configuring compile database in $build/ (one-time; reuses cached deps) ==" + cmake -B "$build" -DCMAKE_EXPORT_COMPILE_COMMANDS=ON >/dev/null +fi + +# File list: the given TUs, or — matching CI exactly — every project TU in the +# database with third_party/ and fetched deps (_deps) excluded. +if [ "$#" -gt 0 ]; then + files=("$@") +else + mapfile -t files < <(python3 -c " +import json +for e in json.load(open('$build/compile_commands.json')): + f = e['file'] + if 'third_party' not in f and '_deps' not in f: + print(f)") +fi + +echo "== clang-tidy ($tidy) over ${#files[@]} file(s) ==" +fail=0 +for f in "${files[@]}"; do + out="$("$tidy" -p "$build" "$f" 2>/dev/null || true)" + if printf '%s\n' "$out" | grep -qE "warning:|error:"; then + printf '%s\n' "$out" + fail=1 + fi +done + +if [ "$fail" -eq 0 ]; then + echo "clang-tidy clean." +else + echo "clang-tidy found violations (above); fix before pushing." >&2 + exit 1 +fi diff --git a/source/projects/tap.python_tilde/tap.python_tilde.cpp b/source/projects/tap.python_tilde/tap.python_tilde.cpp index fcf8520..12953ac 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde.cpp +++ b/source/projects/tap.python_tilde/tap.python_tilde.cpp @@ -7,55 +7,51 @@ using namespace c74::min; using namespace c74::max; - -c74::max::t_max_err python_attr_set(c74::max::t_object* x, c74::max::t_object* maxattr, const long argc, const c74::max::t_atom* argv) { - auto attr_name { static_cast(c74::max::object_method(maxattr, k_sym_getname)) }; +c74::max::t_max_err python_attr_set(c74::max::t_object* x, c74::max::t_object* maxattr, const long argc, + const c74::max::t_atom* argv) { + auto attr_name{static_cast(c74::max::object_method(maxattr, k_sym_getname))}; auto self = &(wrapper_find_self(x))->m_min_object; self->attr_set(attr_name, argc, argv); return c74::max::MAX_ERR_NONE; } - -c74::max::t_max_err python_attr_get(c74::max::t_object* x, c74::max::t_object* maxattr, long* argc, c74::max::t_atom** argv) { - auto attr_name { static_cast(c74::max::object_method(maxattr, k_sym_getname)) }; +c74::max::t_max_err python_attr_get(c74::max::t_object* x, c74::max::t_object* maxattr, long* argc, + c74::max::t_atom** argv) { + auto attr_name{static_cast(c74::max::object_method(maxattr, k_sym_getname))}; auto self = &(wrapper_find_self(x))->m_min_object; self->attr_get(attr_name, argc, argv); return c74::max::MAX_ERR_NONE; } - c74::max::t_max_err python_mess_int(c74::max::t_object* x, long value) { - auto self = &(wrapper_find_self(x))->m_min_object; - c74::max::t_atom a; + auto self = &(wrapper_find_self(x))->m_min_object; + c74::max::t_atom a; c74::max::atom_setlong(&a, value); self->message_gimme(gensym("int"), 1, &a); return c74::max::MAX_ERR_NONE; } - c74::max::t_max_err python_mess_float(c74::max::t_object* x, double value) { - auto self = &(wrapper_find_self(x))->m_min_object; - c74::max::t_atom a; + auto self = &(wrapper_find_self(x))->m_min_object; + c74::max::t_atom a; c74::max::atom_setfloat(&a, value); self->message_gimme(k_sym_float, 1, &a); return c74::max::MAX_ERR_NONE; } - c74::max::t_max_err python_mess_symbol(c74::max::t_object* x, c74::max::t_symbol* value) { - auto self = &(wrapper_find_self(x))->m_min_object; - c74::max::t_atom a; + auto self = &(wrapper_find_self(x))->m_min_object; + c74::max::t_atom a; c74::max::atom_setsym(&a, value); self->message_gimme(gensym("symbol"), 1, &a); return c74::max::MAX_ERR_NONE; } - c74::max::t_max_err python_mess_bang(c74::max::t_object* x) { auto self = &(wrapper_find_self(x))->m_min_object; @@ -63,7 +59,6 @@ c74::max::t_max_err python_mess_bang(c74::max::t_object* x) { return c74::max::MAX_ERR_NONE; } - c74::max::t_max_err python_mess_gimme(c74::max::t_object* x, c74::max::t_symbol* name, long ac, c74::max::t_atom* av) { auto self = &(wrapper_find_self(x))->m_min_object; @@ -71,5 +66,4 @@ c74::max::t_max_err python_mess_gimme(c74::max::t_object* x, c74::max::t_symbol* return c74::max::MAX_ERR_NONE; } - MIN_EXTERNAL(python); diff --git a/source/projects/tap.python_tilde/tap.python_tilde.h b/source/projects/tap.python_tilde/tap.python_tilde.h index 053a1cb..7eae971 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde.h +++ b/source/projects/tap.python_tilde/tap.python_tilde.h @@ -5,98 +5,104 @@ #pragma once #define PY_SSIZE_T_CLEAN -#include // CPython insists on being included before system headers +#include // CPython insists on being included before system headers // c74_min.h must be the FIRST min header in the translation unit: it defines // C74_MIN_WITH_IMPLEMENTATION so the min wrapper's out-of-line statics get // emitted here. If c74_min_api.h sneaks in first (e.g. via our helper headers), // the include guards swallow those definitions and the external fails to link. -#include "c74_min.h" - -#include "tap.python_tilde_runtime.h" -#include "tap.python_tilde_cglue.h" -#include "tap.python_tilde_attribute.h" -#include "tap.python_tilde_message.h" - #include #include #include -using namespace c74::min; -namespace runtime = tap::python_runtime; +#include "c74_min.h" +#include "tap.python_tilde_attribute.h" +#include "tap.python_tilde_cglue.h" +#include "tap.python_tilde_message.h" +#include "tap.python_tilde_runtime.h" +using namespace c74::min; +namespace runtime = tap::python; class python : public object, public vector_operator<> { -public: - MIN_DESCRIPTION {"Process audio with a Python class. Attributes and messages are generated from the class's type-annotated members, and the class's process() method runs on the audio signal. The source file is watched and hot-reloaded on save."}; - MIN_TAGS {"programming"}; - MIN_AUTHOR {"Tim Place"}; - MIN_RELATED {"js, node.script"}; + public: + MIN_DESCRIPTION{ + "Process audio with a Python class. Attributes and messages are generated from the class's type-annotated members, and the class's process() method runs on the audio signal. The source file is watched and hot-reloaded on save."}; + MIN_TAGS{"programming"}; + MIN_AUTHOR{"Tim Place"}; + MIN_RELATED{"js, node.script"}; - inlet<> m_inlet { this, "(signal) input passed to the Python process() method" }; - outlet<> m_outlet_main { this, "(signal) output returned from the Python process() method", "signal" }; + inlet<> m_inlet{this, "(signal) input passed to the Python process() method"}; + outlet<> m_outlet_main{this, "(signal) output returned from the Python process() method", "signal"}; - argument m_source_arg { this, "source", "Python source file in the package's python folder, without the .py extension. It must define a class of the same name." }; + argument m_source_arg{ + this, "source", + "Python source file in the package's python folder, without the .py extension. It must define a class of the same name."}; // Max's filewatcher (created in the constructor) sends this message any // time our python source file is modified. - message<> m_filechanged { this, "filechanged", - MIN_FUNCTION { - cout << "Source file update detected. Reloading." << endl; - update_source(); - return {}; - } - }; - + message<> m_filechanged{this, "filechanged", + MIN_FUNCTION { + cout << "Source file update detected. Reloading." << endl; + update_source(); + return {}; + }}; python(const atoms& args = {}) { - if (maxobj() == NULL) - return; // this occurs during dummy construction + if (maxobj() == NULL) { + return; // this occurs during dummy construction + } const auto package = runtime::package_root(); - m_scripts_dir = package / "python"; - const auto home = package / "support"; + m_scripts_dir = package / "python"; + const auto home = package / "support"; if (!std::filesystem::exists(home)) { cerr << "No Python runtime found at " << home.string() << " — run scripts/install-runtime from the package root to install it." << endl; return; } - if (!runtime::initialize(home, m_scripts_dir)) + if (!runtime::initialize(home, m_scripts_dir)) { return; + } - if (args.empty()) + if (args.empty()) { m_python_source = "default"; - else + } + else { m_python_source = to_string(args); + } update_source(); // watch the source file for changes (delivered as our 'filechanged' message) const auto watched_file = m_scripts_dir / (m_python_source + ".py"); - char filename[c74::max::MAX_PATH_CHARS] {}; + char filename[c74::max::MAX_PATH_CHARS]{}; std::strncpy(filename, watched_file.string().c_str(), c74::max::MAX_PATH_CHARS - 1); - short path_id {}; - c74::max::t_fourcc filetype {}; + short path_id{}; + c74::max::t_fourcc filetype{}; if (c74::max::locatefile_extended(filename, &path_id, &filetype, nullptr, 0) == 0) { m_filewatcher = c74::max::filewatcher_new(maxobj(), path_id, filename); - if (m_filewatcher) + if (m_filewatcher) { c74::max::filewatcher_start(m_filewatcher); + } } - else + else { cerr << "Unable to watch " << watched_file.string() << " for changes." << endl; + } } - ~python() { - if (m_filewatcher) + if (m_filewatcher) { c74::max::object_free(m_filewatcher); + } - if (!Py_IsInitialized()) - return; // dummy construction, or the runtime never came up + if (!Py_IsInitialized()) { + return; // dummy construction, or the runtime never came up + } runtime::gil_lock lock; - m_python_messages.clear(); // releases strong refs to bound functions + m_python_messages.clear(); // releases strong refs to bound functions m_python_attributes.clear(); Py_CLEAR(m_process_fn); Py_CLEAR(m_process_args); @@ -104,14 +110,14 @@ class python : public object, public vector_operator<> { Py_CLEAR(m_module); } - /// (Re)import the user's module, instantiate its class, and rebuild the Max /// attributes and messages from the class's type hints. Called from the Max /// main thread (constructor and file watcher). Holding the GIL for the whole /// rebuild also serializes us against the perform routine. void update_source() { - if (!Py_IsInitialized()) + if (!Py_IsInitialized()) { return; + } runtime::gil_lock lock; @@ -133,17 +139,18 @@ class python : public object, public vector_operator<> { } else { PyObject* name = PyUnicode_DecodeFSDefault(m_python_source.c_str()); - m_module = name ? PyImport_Import(name) : nullptr; + m_module = name ? PyImport_Import(name) : nullptr; Py_XDECREF(name); if (!m_module) { PyErr_Print(); - cerr << "Failed to load module '" << m_python_source << "' (searched " << m_scripts_dir.string() << ")" << endl; + cerr << "Failed to load module '" << m_python_source << "' (searched " << m_scripts_dir.string() << ")" + << endl; return; } } - PyObject* module_dict = PyModule_GetDict(m_module); // borrowed - PyObject* py_class = PyDict_GetItemString(module_dict, m_python_source.c_str()); // borrowed + PyObject* module_dict = PyModule_GetDict(m_module); // borrowed + PyObject* py_class = PyDict_GetItemString(module_dict, m_python_source.c_str()); // borrowed if (!py_class) { cerr << "No class named '" << m_python_source << "' in " << m_python_source << ".py" << endl; return; @@ -164,18 +171,19 @@ class python : public object, public vector_operator<> { create_messages(); } - /// Dispatch a Max message to the bound Python method. /// Runs on the Max main or scheduler thread. void message_gimme(const symbol name, const long ac, const c74::max::t_atom* av) { - if (!Py_IsInitialized() || !m_instance) + if (!Py_IsInitialized() || !m_instance) { return; + } runtime::gil_lock lock; auto found = m_python_messages.find(name.c_str()); - if (found == m_python_messages.end()) + if (found == m_python_messages.end()) { return; + } auto& mess = found->second; const auto& arg_types = mess->arg_types(); @@ -185,35 +193,41 @@ class python : public object, public vector_operator<> { } PyObject* py_args = PyTuple_New(ac + 1); - if (!py_args) + if (!py_args) { return; - Py_INCREF(m_instance); // the tuple *steals* a reference + } + Py_INCREF(m_instance); // the tuple *steals* a reference PyTuple_SetItem(py_args, 0, m_instance); for (auto i = 0; i < ac; ++i) { PyObject* value; - if (arg_types[i] == "int") + if (arg_types[i] == "int") { value = PyLong_FromLong(c74::max::atom_getlong(av + i)); - else if (arg_types[i] == "float") + } + else if (arg_types[i] == "float") { value = PyFloat_FromDouble(c74::max::atom_getfloat(av + i)); - else + } + else { value = PyUnicode_DecodeFSDefault(c74::max::atom_getsym(av + i)->s_name); - PyTuple_SetItem(py_args, i + 1, value); // steals the reference + } + PyTuple_SetItem(py_args, i + 1, value); // steals the reference } PyObject* result = PyObject_Call(mess->function(), py_args, nullptr); - if (result) + if (result) { Py_DECREF(result); - else + } + else { PyErr_Print(); + } Py_DECREF(py_args); } - void attr_set(const symbol& name, const long argc, const c74::max::t_atom* argv) { - if (!Py_IsInitialized() || !m_instance) + if (!Py_IsInitialized() || !m_instance) { return; + } if (argc != 1) { cerr << "Attributes with more than 1 arg not supported" << endl; return; @@ -222,28 +236,32 @@ class python : public object, public vector_operator<> { runtime::gil_lock lock; auto found = m_python_attributes.find(name.c_str()); - if (found == m_python_attributes.end()) + if (found == m_python_attributes.end()) { return; + } auto& attr = found->second; PyObject* value; - if (attr->type() == k_sym_float64) + if (attr->type() == k_sym_float64) { value = PyFloat_FromDouble(c74::max::atom_getfloat(argv)); - else if (attr->type() == k_sym_long) + } + else if (attr->type() == k_sym_long) { value = PyLong_FromLong(c74::max::atom_getlong(argv)); - else + } + else { value = PyUnicode_DecodeFSDefault(c74::max::atom_getsym(argv)->s_name); + } if (value) { - if (PyObject_SetAttrString(m_instance, name.c_str(), value) < 0) - PyErr_Print(); // e.g. an attrs validator rejected the value + if (PyObject_SetAttrString(m_instance, name.c_str(), value) < 0) { + PyErr_Print(); // e.g. an attrs validator rejected the value + } Py_DECREF(value); } } - void attr_get(const symbol& name, long* argc, c74::max::t_atom** argv) { - if ((*argc) != 1 || !(*argv)) { // otherwise use memory passed in + if ((*argc) != 1 || !(*argv)) { // otherwise use memory passed in if (*argc && *argv) { c74::max::sysmem_freeptr(*argv); *argv = NULL; @@ -251,16 +269,18 @@ class python : public object, public vector_operator<> { *argc = 1; *argv = reinterpret_cast(c74::max::sysmem_newptr(sizeof(c74::max::t_atom) * (*argc))); } - c74::max::atom_setfloat(*argv, 0.0); // default in case anything below fails + c74::max::atom_setfloat(*argv, 0.0); // default in case anything below fails - if (!Py_IsInitialized() || !m_instance) + if (!Py_IsInitialized() || !m_instance) { return; + } runtime::gil_lock lock; auto found = m_python_attributes.find(name.c_str()); - if (found == m_python_attributes.end()) + if (found == m_python_attributes.end()) { return; + } auto& attr = found->second; PyObject* value = PyObject_GetAttrString(m_instance, name.c_str()); @@ -269,21 +289,23 @@ class python : public object, public vector_operator<> { return; } - if (attr->type() == k_sym_float64) + if (attr->type() == k_sym_float64) { c74::max::atom_setfloat(*argv, PyFloat_AsDouble(value)); - else if (attr->type() == k_sym_long) + } + else if (attr->type() == k_sym_long) { c74::max::atom_setlong(*argv, PyLong_AsLong(value)); + } else { const char* str = PyUnicode_AsUTF8(value); c74::max::atom_setsym(*argv, c74::max::gensym(str ? str : "")); } - if (PyErr_Occurred()) + if (PyErr_Occurred()) { PyErr_Clear(); + } Py_DECREF(value); } - /// The audio perform routine. Calls the Python process() method once per /// sample — the GIL is taken once per vector, and the GIL is also what /// serializes us against update_source() on the main thread. @@ -295,7 +317,7 @@ class python : public object, public vector_operator<> { runtime::gil_lock lock; - if (!m_process_fn) { // re-check now that we hold the GIL + if (!m_process_fn) { // re-check now that we hold the GIL output.clear(); return; } @@ -304,18 +326,19 @@ class python : public object, public vector_operator<> { auto out = output.samples(0); for (auto i = 0; i < input.frame_count(); ++i) { - PyTuple_SetItem(m_process_args, 1, PyFloat_FromDouble(in[i])); // the tuple steals the pyfloat + PyTuple_SetItem(m_process_args, 1, PyFloat_FromDouble(in[i])); // the tuple steals the pyfloat PyObject* result = PyObject_CallObject(m_process_fn, m_process_args); if (!result) { PyErr_Print(); cerr << "process() raised an exception — audio disabled until the source is fixed and reloaded" << endl; - Py_CLEAR(m_process_fn); // update_source() re-arms it - for (; i < output.frame_count(); ++i) + Py_CLEAR(m_process_fn); // update_source() re-arms it + for (; i < output.frame_count(); ++i) { out[i] = 0.0; + } return; } - double y = PyFloat_AsDouble(result); // also handles ints and other number types + double y = PyFloat_AsDouble(result); // also handles ints and other number types if (PyErr_Occurred()) { PyErr_Clear(); y = 0.0; @@ -325,19 +348,17 @@ class python : public object, public vector_operator<> { } } - -private: - string m_python_source {}; - std::filesystem::path m_scripts_dir {}; - void* m_filewatcher {}; - PyObject* m_module {}; // strong - PyObject* m_instance {}; // strong - PyObject* m_process_fn {}; // strong - PyObject* m_process_args {}; // strong; slot 0 holds a ref to m_instance + private: + string m_python_source{}; + std::filesystem::path m_scripts_dir{}; + void* m_filewatcher{}; + PyObject* m_module{}; // strong + PyObject* m_instance{}; // strong + PyObject* m_process_fn{}; // strong + PyObject* m_process_args{}; // strong; slot 0 holds a ref to m_instance std::unordered_map> m_python_messages; std::unordered_map> m_python_attributes; - /// Create Max attributes from the class-level type hints. /// Caller holds the GIL. void create_attributes(PyObject* py_class) { @@ -350,35 +371,39 @@ class python : public object, public vector_operator<> { PyObject* key; PyObject* value; Py_ssize_t pos = 0; - while (PyDict_Next(hints, &pos, &key, &value)) { // key/value are borrowed + while (PyDict_Next(hints, &pos, &key, &value)) { // key/value are borrowed const char* key_cstr = PyUnicode_AsUTF8(key); - if (!key_cstr || key_cstr[0] == '_') + if (!key_cstr || key_cstr[0] == '_') { continue; + } - string type_str = "str"; + string type_str = "str"; PyObject* type_name = PyObject_GetAttrString(value, "__name__"); if (type_name) { - if (const char* s = PyUnicode_AsUTF8(type_name)) + if (const char* s = PyUnicode_AsUTF8(type_name)) { type_str = s; + } Py_DECREF(type_name); } - else - PyErr_Clear(); // e.g. a typing generic without __name__; treat as str + else { + PyErr_Clear(); // e.g. a typing generic without __name__; treat as str + } // attributes persist across reloads; only create ones we don't have yet - if (m_python_attributes.find(key_cstr) == m_python_attributes.end()) + if (m_python_attributes.find(key_cstr) == m_python_attributes.end()) { m_python_attributes[key_cstr] = std::make_unique(maxobj(), key_cstr, type_str); + } } Py_DECREF(hints); } - /// Create Max messages from the instance's public methods, and bind process(). /// Caller holds the GIL. void create_messages() { // remove the previous incarnation's messages before rebinding - for (auto& element : m_python_messages) + for (auto& element : m_python_messages) { c74::max::object_deletemethod(maxobj(), c74::max::gensym(element.first.c_str())); + } m_python_messages.clear(); PyObject* members = PyObject_Dir(m_instance); @@ -389,14 +414,16 @@ class python : public object, public vector_operator<> { const auto member_count = PyList_Size(members); for (Py_ssize_t i = 0; i < member_count; ++i) { - PyObject* member = PyList_GetItem(members, i); // borrowed + PyObject* member = PyList_GetItem(members, i); // borrowed const char* name_cstr = member ? PyUnicode_AsUTF8(member) : nullptr; - if (!name_cstr || name_cstr[0] == '_') + if (!name_cstr || name_cstr[0] == '_') { continue; + } - const string member_name { name_cstr }; - if (m_python_attributes.find(member_name) != m_python_attributes.end()) + const string member_name{name_cstr}; + if (m_python_attributes.find(member_name) != m_python_attributes.end()) { continue; + } PyObject* method = PyObject_GetAttrString(m_instance, member_name.c_str()); if (!method) { @@ -405,16 +432,19 @@ class python : public object, public vector_operator<> { } if (PyMethod_Check(method)) { - PyObject* fn = PyMethod_Function(method); // borrowed + PyObject* fn = PyMethod_Function(method); // borrowed if (fn && PyFunction_Check(fn)) { PyObject* hints = runtime::get_type_hints(method); - if (!hints) + if (!hints) { PyErr_Clear(); + } - if (member_name == "process") + if (member_name == "process") { bind_process(fn, hints); - else + } + else { bind_message(member_name, fn, hints); + } Py_XDECREF(hints); } @@ -424,21 +454,21 @@ class python : public object, public vector_operator<> { Py_DECREF(members); } - /// Bind the class's process() method as the per-sample audio callback. /// Caller holds the GIL. void bind_process(PyObject* fn, PyObject* hints) { - int in_count = 0; + int in_count = 0; bool returns_tuple = false; if (hints) { PyObject* key; PyObject* value; Py_ssize_t pos = 0; - while (PyDict_Next(hints, &pos, &key, &value)) { // borrowed + while (PyDict_Next(hints, &pos, &key, &value)) { // borrowed const char* key_cstr = PyUnicode_AsUTF8(key); - if (!key_cstr) + if (!key_cstr) { continue; + } if (string("return") == key_cstr) { PyObject* type_name = PyObject_GetAttrString(value, "__name__"); if (type_name) { @@ -446,27 +476,33 @@ class python : public object, public vector_operator<> { returns_tuple = (s && string("tuple") == s); Py_DECREF(type_name); } - else + else { PyErr_Clear(); + } } - else + else { ++in_count; + } } } - if (in_count > 1) - cerr << "process() declares " << in_count << " inputs but only the first is supported (single-channel object)" << endl; + if (in_count > 1) { + cerr << "process() declares " << in_count + << " inputs but only the first is supported (single-channel object)" << endl; + } if (returns_tuple) { - cerr << "process() returns a tuple — multichannel output is not supported yet; use a single float return" << endl; + cerr << "process() returns a tuple — multichannel output is not supported yet; use a single float return" + << endl; return; } m_process_args = PyTuple_New(2); - if (!m_process_args) + if (!m_process_args) { return; - Py_INCREF(m_instance); // the tuple *steals* a reference + } + Py_INCREF(m_instance); // the tuple *steals* a reference PyTuple_SetItem(m_process_args, 0, m_instance); - PyTuple_SetItem(m_process_args, 1, PyFloat_FromDouble(0.0)); // placeholder; replaced every sample + PyTuple_SetItem(m_process_args, 1, PyFloat_FromDouble(0.0)); // placeholder; replaced every sample Py_INCREF(fn); m_process_fn = fn; @@ -474,7 +510,6 @@ class python : public object, public vector_operator<> { cout << "Audio process() bound: 1 input, 1 output" << endl; } - /// Bind a public method as a Max message. /// Caller holds the GIL. void bind_message(const string& name, PyObject* fn, PyObject* hints) { @@ -484,20 +519,23 @@ class python : public object, public vector_operator<> { PyObject* key; PyObject* value; Py_ssize_t pos = 0; - while (PyDict_Next(hints, &pos, &key, &value)) { // borrowed + while (PyDict_Next(hints, &pos, &key, &value)) { // borrowed const char* key_cstr = PyUnicode_AsUTF8(key); - if (!key_cstr || string("return") == key_cstr) + if (!key_cstr || string("return") == key_cstr) { continue; + } - string type_str = "str"; + string type_str = "str"; PyObject* type_name = PyObject_GetAttrString(value, "__name__"); if (type_name) { - if (const char* s = PyUnicode_AsUTF8(type_name)) + if (const char* s = PyUnicode_AsUTF8(type_name)) { type_str = s; + } Py_DECREF(type_name); } - else + else { PyErr_Clear(); + } argument_types.push_back(type_str); } } diff --git a/source/projects/tap.python_tilde/tap.python_tilde_attribute.h b/source/projects/tap.python_tilde/tap.python_tilde_attribute.h index 054bb6a..46b0d7b 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_attribute.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_attribute.h @@ -10,47 +10,49 @@ class python; using namespace c74::min; - /// A Max attribute dynamically added to a tap.python~ instance, mirroring an /// annotated attribute of the user's Python class. Instances are owned via /// unique_ptr in the object's attribute map and persist across module reloads /// (the underlying Max attribute object stays registered on the Max object). class python_attr { -public: + public: python_attr(c74::max::t_object* owner, const string& a_name, const string& a_type) - : m_owner { owner } - , m_name { a_name } - , m_typename { a_type } - { - if (m_typename == "int") + : m_owner{owner} + , m_name{a_name} + , m_typename{a_type} { + if (m_typename == "int") { m_type = k_sym_long; - else if (m_typename == "float") + } + else if (m_typename == "float") { m_type = k_sym_float64; - else + } + else { m_type = k_sym_symbol; + } - m_attrobj = c74::max::attribute_new(m_name.c_str(), m_type, 0, (c74::max::method)python_attr_get, (c74::max::method)python_attr_set); - auto err = c74::max::object_addattr(m_owner, m_attrobj); - if (err) + m_attrobj = c74::max::attribute_new(m_name.c_str(), m_type, 0, (c74::max::method)python_attr_get, + (c74::max::method)python_attr_set); + auto err = c74::max::object_addattr(m_owner, m_attrobj); + if (err) { c74::max::object_error(m_owner, "failed to add attribute for python member '%s'", m_name.c_str()); + } else { err = c74::max::object_attr_addattr_parse(m_owner, m_name.c_str(), "dynamicattr", k_sym_long, 0, "1"); - if (err) + if (err) { c74::max::object_error(m_owner, "failed to make dynamic attribute for %s", m_name.c_str()); + } } } - python_attr(const python_attr&) = delete; + python_attr(const python_attr&) = delete; python_attr& operator=(const python_attr&) = delete; - symbol type() const { - return m_type; - } + symbol type() const { return m_type; } -private: + private: c74::max::t_object* m_owner; string m_name; string m_typename; symbol m_type; - c74::max::t_object* m_attrobj {}; + c74::max::t_object* m_attrobj{}; }; diff --git a/source/projects/tap.python_tilde/tap.python_tilde_cglue.h b/source/projects/tap.python_tilde/tap.python_tilde_cglue.h index e5ef274..cf1c942 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_cglue.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_cglue.h @@ -8,8 +8,10 @@ // messages that are created dynamically from the user's Python class. // Implementations are in tap.python_tilde.cpp. -c74::max::t_max_err python_attr_set(c74::max::t_object* x, c74::max::t_object* maxattr, const long argc, const c74::max::t_atom* argv); -c74::max::t_max_err python_attr_get(c74::max::t_object* x, c74::max::t_object* maxattr, long* argc, c74::max::t_atom** argv); +c74::max::t_max_err python_attr_set(c74::max::t_object* x, c74::max::t_object* maxattr, const long argc, + const c74::max::t_atom* argv); +c74::max::t_max_err python_attr_get(c74::max::t_object* x, c74::max::t_object* maxattr, long* argc, + c74::max::t_atom** argv); c74::max::t_max_err python_mess_int(c74::max::t_object* x, long value); c74::max::t_max_err python_mess_float(c74::max::t_object* x, double value); diff --git a/source/projects/tap.python_tilde/tap.python_tilde_message.h b/source/projects/tap.python_tilde/tap.python_tilde_message.h index 034abcf..748c82c 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_message.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_message.h @@ -10,60 +10,63 @@ class python; using namespace c74::min; - /// A Max message dynamically added to a tap.python~ instance, bound to a method /// of the user's Python class. Owns a strong reference to the Python function so /// the binding survives (and is correctly released across) module reloads. /// Instances are owned via unique_ptr in the object's message map; the caller is /// responsible for holding the GIL during construction and destruction. class python_message { -public: + public: python_message(c74::max::t_object* owner, const string& a_name, PyObject* a_function, const strings& in_types) - : m_owner { owner } - , m_name { a_name } - , m_function { a_function } - , m_in_types { in_types } - { - if (m_owner == nullptr) - return; // this occurs during dummy construction + : m_owner{owner} + , m_name{a_name} + , m_function{a_function} + , m_in_types{in_types} { + if (m_owner == nullptr) { + return; // this occurs during dummy construction + } Py_XINCREF(m_function); - c74::max::t_max_err err {}; + c74::max::t_max_err err{}; - if (m_name == "int") - err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_int, m_name.c_str(), c74::max::A_LONG, 0); - else if (m_name == "float") - err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_float, m_name.c_str(), c74::max::A_FLOAT, 0); - else if (m_name == "symbol") - err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_symbol, m_name.c_str(), c74::max::A_SYM, 0); - else if (m_name == "bang") + if (m_name == "int") { + err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_int, m_name.c_str(), + c74::max::A_LONG, 0); + } + else if (m_name == "float") { + err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_float, m_name.c_str(), + c74::max::A_FLOAT, 0); + } + else if (m_name == "symbol") { + err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_symbol, m_name.c_str(), + c74::max::A_SYM, 0); + } + else if (m_name == "bang") { err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_bang, m_name.c_str(), 0); - else - err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_gimme, m_name.c_str(), c74::max::A_GIMME, 0); + } + else { + err = c74::max::object_addmethod(m_owner, (c74::max::method)python_mess_gimme, m_name.c_str(), + c74::max::A_GIMME, 0); + } - if (err) + if (err) { c74::max::object_error(m_owner, "failed to add message for python method '%s'", m_name.c_str()); + } } - ~python_message() { - Py_XDECREF(m_function); - } + ~python_message() { Py_XDECREF(m_function); } - python_message(const python_message&) = delete; + python_message(const python_message&) = delete; python_message& operator=(const python_message&) = delete; - PyObject* function() const { - return m_function; - } + PyObject* function() const { return m_function; } - const strings& arg_types() const { - return m_in_types; - } + const strings& arg_types() const { return m_in_types; } -private: + private: c74::max::t_object* m_owner; string m_name; - PyObject* m_function; // strong reference + PyObject* m_function; // strong reference strings m_in_types; }; diff --git a/source/projects/tap.python_tilde/tap.python_tilde_runtime.h b/source/projects/tap.python_tilde/tap.python_tilde_runtime.h index 265c4e3..fea9a61 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_runtime.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_runtime.h @@ -13,214 +13,206 @@ #pragma once #define PY_SSIZE_T_CLEAN -#include - #include #include #include +#include + #ifdef WIN_VERSION - #include +#include #else - #include +#include #endif #include "c74_min_api.h" +namespace tap::python { -namespace tap::python_runtime { - - -/// Full path of this external's binary on disk, found from the address of one of -/// our own functions. This works no matter where the user installed the package. -inline std::filesystem::path external_binary_path() { + /// Full path of this external's binary on disk, found from the address of one of + /// our own functions. This works no matter where the user installed the package. + inline std::filesystem::path external_binary_path() { #ifdef WIN_VERSION - HMODULE module {}; - GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, - reinterpret_cast(&external_binary_path), &module); - wchar_t buffer[4096] {}; - GetModuleFileNameW(module, buffer, 4096); - return std::filesystem::path { buffer }; + HMODULE module{}; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&external_binary_path), &module); + wchar_t buffer[4096]{}; + GetModuleFileNameW(module, buffer, 4096); + return std::filesystem::path{buffer}; #else - Dl_info info {}; - dladdr(reinterpret_cast(&external_binary_path), &info); - return std::filesystem::path { info.dli_fname }; + Dl_info info{}; + dladdr(reinterpret_cast(&external_binary_path), &info); + return std::filesystem::path{info.dli_fname}; #endif -} - + } -/// Root of the Max package containing this external. -/// mac: /externals/tap.python~.mxo/Contents/MacOS/tap.python~ -/// win: /externals/tap.python~.mxe64 -inline std::filesystem::path package_root() { - auto p = external_binary_path(); + /// Root of the Max package containing this external. + /// mac: /externals/tap.python~.mxo/Contents/MacOS/tap.python~ + /// win: /externals/tap.python~.mxe64 + inline std::filesystem::path package_root() { + auto p = external_binary_path(); #ifdef MAC_VERSION - return p.parent_path().parent_path().parent_path().parent_path().parent_path(); + return p.parent_path().parent_path().parent_path().parent_path().parent_path(); #else - return p.parent_path().parent_path(); + return p.parent_path().parent_path(); #endif -} + } + /// RAII guard for the GIL. Every call into the CPython API after initialization + /// must hold one of these — messages and attribute accessors arrive on the Max + /// main/scheduler threads while the perform routine runs on the audio thread. + class gil_lock { + public: + gil_lock() + : m_state{PyGILState_Ensure()} {} + + ~gil_lock() { PyGILState_Release(m_state); } + + gil_lock(const gil_lock&) = delete; + gil_lock& operator=(const gil_lock&) = delete; + + private: + PyGILState_STATE m_state; + }; + + // _maxconsole: a tiny built-in module that Python's sys.stdout/sys.stderr are + // rebound to, so that print() and tracebacks land in the Max console instead of + // disappearing (the old code redirected them to a log file at a hardcoded path). + // Output is buffered per-stream and flushed to the console a line at a time. + + inline std::mutex s_console_mutex; + inline std::string s_console_buffer_out; + inline std::string s_console_buffer_err; + + inline void console_post(std::string& buffer, const char* str, const bool is_error) { + std::lock_guard lock{s_console_mutex}; + buffer += str; + size_t pos; + while ((pos = buffer.find('\n')) != std::string::npos) { + const auto line = buffer.substr(0, pos); + if (is_error) { + c74::max::object_error(nullptr, "python: %s", line.c_str()); + } + else { + c74::max::object_post(nullptr, "python: %s", line.c_str()); + } + buffer.erase(0, pos + 1); + } + } -/// RAII guard for the GIL. Every call into the CPython API after initialization -/// must hold one of these — messages and attribute accessors arrive on the Max -/// main/scheduler threads while the perform routine runs on the audio thread. -class gil_lock { -public: - gil_lock() - : m_state { PyGILState_Ensure() } - {} + inline PyObject* console_write(PyObject*, PyObject* args) { + const char* str{}; + if (!PyArg_ParseTuple(args, "s", &str)) { + return nullptr; + } + console_post(s_console_buffer_out, str, false); + Py_RETURN_NONE; + } - ~gil_lock() { - PyGILState_Release(m_state); + inline PyObject* console_write_err(PyObject*, PyObject* args) { + const char* str{}; + if (!PyArg_ParseTuple(args, "s", &str)) { + return nullptr; + } + console_post(s_console_buffer_err, str, true); + Py_RETURN_NONE; } - gil_lock(const gil_lock&) = delete; - gil_lock& operator=(const gil_lock&) = delete; - -private: - PyGILState_STATE m_state; -}; - - -// _maxconsole: a tiny built-in module that Python's sys.stdout/sys.stderr are -// rebound to, so that print() and tracebacks land in the Max console instead of -// disappearing (the old code redirected them to a log file at a hardcoded path). -// Output is buffered per-stream and flushed to the console a line at a time. - -inline std::mutex s_console_mutex; -inline std::string s_console_buffer_out; -inline std::string s_console_buffer_err; - -inline void console_post(std::string& buffer, const char* str, const bool is_error) { - std::lock_guard lock { s_console_mutex }; - buffer += str; - size_t pos; - while ((pos = buffer.find('\n')) != std::string::npos) { - const auto line = buffer.substr(0, pos); - if (is_error) - c74::max::object_error(nullptr, "python: %s", line.c_str()); - else - c74::max::object_post(nullptr, "python: %s", line.c_str()); - buffer.erase(0, pos + 1); + inline PyMethodDef s_console_methods[] = { + {"write", console_write, METH_VARARGS, "Write to the Max console."}, + {"write_err", console_write_err, METH_VARARGS, "Write to the Max console as an error."}, + {nullptr, nullptr, 0, nullptr}}; + + inline PyModuleDef s_console_moduledef = { + PyModuleDef_HEAD_INIT, "_maxconsole", nullptr, -1, s_console_methods, nullptr, nullptr, nullptr, nullptr}; + + inline PyObject* console_module_init() { + return PyModule_Create(&s_console_moduledef); } -} - -inline PyObject* console_write(PyObject*, PyObject* args) { - const char* str {}; - if (!PyArg_ParseTuple(args, "s", &str)) - return nullptr; - console_post(s_console_buffer_out, str, false); - Py_RETURN_NONE; -} - -inline PyObject* console_write_err(PyObject*, PyObject* args) { - const char* str {}; - if (!PyArg_ParseTuple(args, "s", &str)) - return nullptr; - console_post(s_console_buffer_err, str, true); - Py_RETURN_NONE; -} - -inline PyMethodDef s_console_methods[] = { - { "write", console_write, METH_VARARGS, "Write to the Max console." }, - { "write_err", console_write_err, METH_VARARGS, "Write to the Max console as an error." }, - { nullptr, nullptr, 0, nullptr } -}; - -inline PyModuleDef s_console_moduledef = { - PyModuleDef_HEAD_INIT, "_maxconsole", nullptr, -1, s_console_methods, - nullptr, nullptr, nullptr, nullptr -}; - -inline PyObject* console_module_init() { - return PyModule_Create(&s_console_moduledef); -} - - -/// Initialize the embedded interpreter exactly once per Max session, from the -/// runtime in /support. Returns true if the interpreter is available. -/// The GIL is released after initialization; all later API calls take gil_lock. -/// -/// Note: the interpreter is never finalized — it lives until Max quits. All -/// tap.python~ instances share it (CPython supports only one interpreter here). -inline bool initialize(const std::filesystem::path& home, const std::filesystem::path& scripts_dir) { - static std::once_flag s_once; - static bool s_ok = false; - std::call_once(s_once, [&] { - PyImport_AppendInittab("_maxconsole", console_module_init); + /// Initialize the embedded interpreter exactly once per Max session, from the + /// runtime in /support. Returns true if the interpreter is available. + /// The GIL is released after initialization; all later API calls take gil_lock. + /// + /// Note: the interpreter is never finalized — it lives until Max quits. All + /// tap.python~ instances share it (CPython supports only one interpreter here). + inline bool initialize(const std::filesystem::path& home, const std::filesystem::path& scripts_dir) { + static std::once_flag s_once; + static bool s_ok = false; + + std::call_once(s_once, [&] { + PyImport_AppendInittab("_maxconsole", console_module_init); - PyConfig config; - PyConfig_InitIsolatedConfig(&config); // ignore environment variables and user site-packages - config.install_signal_handlers = 0; // we are a plugin: never steal signal handling from Max - config.parse_argv = 0; + PyConfig config; + PyConfig_InitIsolatedConfig(&config); // ignore environment variables and user site-packages + config.install_signal_handlers = 0; // we are a plugin: never steal signal handling from Max + config.parse_argv = 0; #ifdef WIN_VERSION - PyStatus status = PyConfig_SetString(&config, &config.home, home.wstring().c_str()); + PyStatus status = PyConfig_SetString(&config, &config.home, home.wstring().c_str()); #else PyStatus status = PyConfig_SetBytesString(&config, &config.home, home.string().c_str()); #endif - if (!PyStatus_Exception(status)) - status = Py_InitializeFromConfig(&config); - PyConfig_Clear(&config); - - if (PyStatus_Exception(status)) { - c74::max::object_error(nullptr, - "tap.python~: failed to start Python from '%s': %s (run scripts/install-runtime to install the runtime)", - home.string().c_str(), status.err_msg ? status.err_msg : "unknown error"); - return; - } - - // Make /python (the user's script folder) importable. - { - PyObject* sys_path = PySys_GetObject("path"); // borrowed + if (!PyStatus_Exception(status)) + status = Py_InitializeFromConfig(&config); + PyConfig_Clear(&config); + + if (PyStatus_Exception(status)) { + c74::max::object_error( + nullptr, + "tap.python~: failed to start Python from '%s': %s (run scripts/install-runtime to install the runtime)", + home.string().c_str(), status.err_msg ? status.err_msg : "unknown error"); + return; + } + + // Make /python (the user's script folder) importable. + { + PyObject* sys_path = PySys_GetObject("path"); // borrowed #ifdef WIN_VERSION - PyObject* dir = PyUnicode_FromWideChar(scripts_dir.wstring().c_str(), -1); + PyObject* dir = PyUnicode_FromWideChar(scripts_dir.wstring().c_str(), -1); #else PyObject* dir = PyUnicode_DecodeFSDefault(scripts_dir.string().c_str()); #endif - if (sys_path && dir) - PyList_Insert(sys_path, 0, dir); // does not steal the reference - Py_XDECREF(dir); + if (sys_path && dir) + PyList_Insert(sys_path, 0, dir); // does not steal the reference + Py_XDECREF(dir); + } + + // Route print() and tracebacks to the Max console. + PyRun_SimpleString("import sys, _maxconsole\n" + "class _MaxConsoleStream:\n" + " def __init__(self, write):\n" + " self._write = write\n" + " def write(self, s):\n" + " self._write(s)\n" + " return len(s)\n" + " def flush(self):\n" + " pass\n" + "sys.stdout = _MaxConsoleStream(_maxconsole.write)\n" + "sys.stderr = _MaxConsoleStream(_maxconsole.write_err)\n"); + + PyEval_SaveThread(); // release the GIL; every entry point re-acquires via gil_lock + s_ok = true; + }); + + return s_ok; + } + + /// typing.get_type_hints(obj) — returns a NEW reference to the hints dict, or + /// nullptr with a Python error set (caller clears it). + inline PyObject* get_type_hints(PyObject* obj) { + PyObject* typing = PyImport_ImportModule("typing"); + if (!typing) { + return nullptr; + } + PyObject* fn = PyObject_GetAttrString(typing, "get_type_hints"); + Py_DECREF(typing); + if (!fn) { + return nullptr; } + PyObject* result = PyObject_CallFunctionObjArgs(fn, obj, nullptr); + Py_DECREF(fn); + return result; + } - // Route print() and tracebacks to the Max console. - PyRun_SimpleString( - "import sys, _maxconsole\n" - "class _MaxConsoleStream:\n" - " def __init__(self, write):\n" - " self._write = write\n" - " def write(self, s):\n" - " self._write(s)\n" - " return len(s)\n" - " def flush(self):\n" - " pass\n" - "sys.stdout = _MaxConsoleStream(_maxconsole.write)\n" - "sys.stderr = _MaxConsoleStream(_maxconsole.write_err)\n"); - - PyEval_SaveThread(); // release the GIL; every entry point re-acquires via gil_lock - s_ok = true; - }); - - return s_ok; -} - - -/// typing.get_type_hints(obj) — returns a NEW reference to the hints dict, or -/// nullptr with a Python error set (caller clears it). -inline PyObject* get_type_hints(PyObject* obj) { - PyObject* typing = PyImport_ImportModule("typing"); - if (!typing) - return nullptr; - PyObject* fn = PyObject_GetAttrString(typing, "get_type_hints"); - Py_DECREF(typing); - if (!fn) - return nullptr; - PyObject* result = PyObject_CallFunctionObjArgs(fn, obj, nullptr); - Py_DECREF(fn); - return result; -} - - -} // namespace tap::python_runtime +} // namespace tap::python diff --git a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp index 7e38fd7..dbc37de 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp +++ b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp @@ -4,7 +4,7 @@ #include -#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) +#include "c74_min_unittest.h" // required unit-test header (defines main via Catch) // The mock kernel does not implement these Max functions, which the object // references for its dynamically generated attributes/messages and its file @@ -12,8 +12,8 @@ // them with C linkage); none of them run in the no-runtime path exercised // below. namespace c74 { -namespace max { - extern "C" { + namespace max { + extern "C" { t_object* attribute_new(const char*, t_symbol*, long, method, method) { return nullptr; } @@ -33,12 +33,11 @@ namespace max { return nullptr; } void filewatcher_start(void*) {} - } -} // namespace max -} // namespace c74 - -#include "tap.python_tilde.cpp" // include the object source so we can instantiate it + } + } // namespace max +} // namespace c74 +#include "tap.python_tilde.cpp" // include the object source so we can instantiate it // The object locates its package relative to the executable. In most test // environments no support/ runtime exists at that location, so construction @@ -58,24 +57,26 @@ SCENARIO("object instantiates, with or without a Python runtime") { WHEN("audio is processed") { std::vector input(64, 0.5); - std::vector output(64, 1.0); // non-zero so we can tell clear() ran - double* inp[1] = { input.data() }; - double* outp[1] = { output.data() }; - audio_bundle ina { inp, 1, static_cast(input.size()) }; - audio_bundle outa { outp, 1, static_cast(output.size()) }; + std::vector output(64, 1.0); // non-zero so we can tell clear() ran + double* inp[1] = {input.data()}; + double* outp[1] = {output.data()}; + audio_bundle ina{inp, 1, static_cast(input.size())}; + audio_bundle outa{outp, 1, static_cast(output.size())}; my_object(ina, outa); if (Py_IsInitialized()) { THEN("default.py processes the audio at unity gain") { - for (size_t i = 0; i < output.size(); ++i) + for (size_t i = 0; i < output.size(); ++i) { REQUIRE(output[i] == input[i]); + } } } else { THEN("the object outputs silence instead of crashing") { - for (auto& s : output) + for (auto& s : output) { REQUIRE(s == 0.0); + } } } } From b5b6b7e89a6e9c79e4e859975478b09dbdd13211 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:24:05 +0000 Subject: [PATCH 2/6] Fix build: make the tap.python~ helper headers self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical TapHouse .clang-format sets SortIncludes, which alphabetized the include block in tap.python_tilde.h and moved the attribute/message headers ahead of cglue.h. Those headers use cglue.h's C trampoline declarations (python_attr_get/set, python_mess_*) but did not include it — they had relied on the previous hand-ordered includes — so the reorder broke the macOS/Windows builds (and the clang-tidy gate, which compiles the same TU). Make the headers depend on what they use, so include order no longer matters: - tap.python_tilde_cglue.h: include c74_min_api.h (it uses c74::max types). - tap.python_tilde_attribute.h / _message.h: include the cglue header. Verified: tap.python_tilde.cpp now syntax-compiles clean (0 errors) against min-api + Python headers; the pre-fix attribute.h reproduced exactly the two undeclared-identifier errors from CI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRTDgYKY38WH8Bqz17W9k1 --- source/projects/tap.python_tilde/tap.python_tilde_attribute.h | 1 + source/projects/tap.python_tilde/tap.python_tilde_cglue.h | 2 ++ source/projects/tap.python_tilde/tap.python_tilde_message.h | 1 + 3 files changed, 4 insertions(+) diff --git a/source/projects/tap.python_tilde/tap.python_tilde_attribute.h b/source/projects/tap.python_tilde/tap.python_tilde_attribute.h index 46b0d7b..4e259cd 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_attribute.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_attribute.h @@ -5,6 +5,7 @@ #pragma once #include "c74_min_api.h" +#include "tap.python_tilde_cglue.h" class python; diff --git a/source/projects/tap.python_tilde/tap.python_tilde_cglue.h b/source/projects/tap.python_tilde/tap.python_tilde_cglue.h index cf1c942..aecba62 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_cglue.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_cglue.h @@ -4,6 +4,8 @@ #pragma once +#include "c74_min_api.h" + // C-style trampolines registered on the Max class for the attributes and // messages that are created dynamically from the user's Python class. // Implementations are in tap.python_tilde.cpp. diff --git a/source/projects/tap.python_tilde/tap.python_tilde_message.h b/source/projects/tap.python_tilde/tap.python_tilde_message.h index 748c82c..9c5cc81 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_message.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_message.h @@ -5,6 +5,7 @@ #pragma once #include "c74_min_api.h" +#include "tap.python_tilde_cglue.h" class python; From 74d2811218930898bf8d5519e29401ef97fb6f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:33:42 +0000 Subject: [PATCH 3/6] Fix the clang-tidy gate: single-arch compile DB, scope to own sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues surfaced once the build was green and clang-tidy actually ran: 1. The package defaults to a universal macOS build (arm64;x86_64); a universal compile command carries two -arch flags, so clang-tidy sees two compiler jobs and bails ("expected exactly one compiler job"). Configure the compile-database build single-arch (-DCMAKE_OSX_ARCHITECTURES=arm64) — the arch is set conditionally in CMakeLists, so the override takes. 2. The clang-tidy invocation used the canonical .clang-tidy HeaderFilterRegex (include|tests), which matches the min-api submodule's include/ and flooded the run with min-api's template-parameter naming. Pass -header-filter='.*/source/projects/.*' (as the other Max repos do) to scope diagnostics to this package's own sources. That header-filter also newly lints the package's own headers, which caught two unbraced if-statements in tap.python_tilde_runtime.h that clang-format's InsertBraces had skipped (both sit right after an #endif — a known InsertBraces limitation across preprocessor boundaries). Braced them. Verified locally with clang-tidy-18: both TUs clean under --warnings-as-errors= readability-* with the source/projects header filter. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRTDgYKY38WH8Bqz17W9k1 --- .github/workflows/style.yml | 12 ++++++++---- .../tap.python_tilde/tap.python_tilde_runtime.h | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index cb2ef8f..015538b 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -36,14 +36,18 @@ jobs: - name: Install clang-tidy (LLVM 18) run: brew install llvm@18 - name: Configure (compile database) - run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON - - name: clang-tidy (project TUs; min-api and fetched deps excluded) + # Single-arch: the package otherwise defaults to a universal build + # (arm64;x86_64), whose two -arch flags make one compile command emit + # two compiler jobs — which clang-tidy cannot process ("expected exactly + # one compiler job"). One arch is enough to lint the sources. + run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_OSX_ARCHITECTURES=arm64 + - name: clang-tidy (Max externals' own TUs; min-api and deps excluded) run: | tidy="$(brew --prefix llvm@18)/bin/clang-tidy" - files=$(python3 -c "import json; print('\n'.join(e['file'] for e in json.load(open('build/compile_commands.json')) if 'source/projects' in e['file']))") + files=$(python3 -c "import json; print('\n'.join(e['file'] for e in json.load(open('build/compile_commands.json')) if '/source/projects/' in e['file']))") fail=0 for f in $files; do - out=$("$tidy" -p build --warnings-as-errors='readability-*' "$f" 2>/dev/null || true) + out=$("$tidy" -p build -header-filter='.*/source/projects/.*' --warnings-as-errors='readability-*' "$f" 2>/dev/null || true) if echo "$out" | grep -qE "warning:|error:"; then echo "$out"; fail=1; fi done [ "$fail" -eq 0 ] && echo "clang-tidy clean." || { echo "::error::clang-tidy found violations"; exit 1; } diff --git a/source/projects/tap.python_tilde/tap.python_tilde_runtime.h b/source/projects/tap.python_tilde/tap.python_tilde_runtime.h index fea9a61..45ff375 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_runtime.h +++ b/source/projects/tap.python_tilde/tap.python_tilde_runtime.h @@ -153,8 +153,9 @@ namespace tap::python { #else PyStatus status = PyConfig_SetBytesString(&config, &config.home, home.string().c_str()); #endif - if (!PyStatus_Exception(status)) + if (!PyStatus_Exception(status)) { status = Py_InitializeFromConfig(&config); + } PyConfig_Clear(&config); if (PyStatus_Exception(status)) { @@ -173,8 +174,9 @@ namespace tap::python { #else PyObject* dir = PyUnicode_DecodeFSDefault(scripts_dir.string().c_str()); #endif - if (sys_path && dir) + if (sys_path && dir) { PyList_Insert(sys_path, 0, dir); // does not steal the reference + } Py_XDECREF(dir); } From 63ebd75a356bb8eb9c4e6873deeec339b3072119 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:39:43 +0000 Subject: [PATCH 4/6] clang-tidy: scope the header filter to the object's own headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous -header-filter='.*/source/projects/.*' also matched the bundled CPython headers: the build includes them via a path that passes back through source/projects (…/tap.python_tilde/../../../support/include/python3.13/…), so the filter linted all of CPython and failed on its naming. (This slipped past local checks because Ubuntu's clang-tidy canonicalizes the include path, hiding the source/projects segment; the macOS CI clang-tidy keeps the literal path.) Narrow the filter to '.*/tap\.python_tilde/tap\.python_tilde.*', which matches only this object's own tap.python_tilde_*.h headers and excludes both the CPython support tree and min-api. Verified against the exact CI path strings. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRTDgYKY38WH8Bqz17W9k1 --- .github/workflows/style.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 015538b..a49adaa 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -46,8 +46,13 @@ jobs: tidy="$(brew --prefix llvm@18)/bin/clang-tidy" files=$(python3 -c "import json; print('\n'.join(e['file'] for e in json.load(open('build/compile_commands.json')) if '/source/projects/' in e['file']))") fail=0 + # Scope diagnostics to this object's OWN headers. A broad + # '.*/source/projects/.*' also matches the bundled CPython headers, + # which the build includes via a relative path that passes back + # through source/projects (…/tap.python_tilde/../../../support/…), + # flooding the run with CPython naming violations. for f in $files; do - out=$("$tidy" -p build -header-filter='.*/source/projects/.*' --warnings-as-errors='readability-*' "$f" 2>/dev/null || true) + out=$("$tidy" -p build -header-filter='.*/tap\.python_tilde/tap\.python_tilde.*' --warnings-as-errors='readability-*' "$f" 2>/dev/null || true) if echo "$out" | grep -qE "warning:|error:"; then echo "$out"; fail=1; fi done [ "$fail" -eq 0 ] && echo "clang-tidy clean." || { echo "::error::clang-tidy found violations"; exit 1; } From da894b0801ff5bef15f7c09fdcb814ba1f756be2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:43:59 +0000 Subject: [PATCH 5/6] clang-tidy CI: use LLVM 20 to parse the macOS SDK's libc++ With the header filter fixed, clang-tidy stopped flooding on CPython and hit a real toolchain wall: llvm@18's clang-tidy cannot parse the macOS 26 SDK's libc++ (// use __builtin_ctzg / __builtin_clzg, generic bit-count builtins added in Clang 19). The TU failed to compile, so clang-tidy reported clang-diagnostic-errors and the gate failed. Install llvm@20 for the macOS clang-tidy leg instead. Behavior is unchanged: the canonical .clang-tidy enables only readability-identifier-naming and readability-braces-around-statements, both config-driven and stable across clang-tidy versions; the newer toolchain just understands the current SDK. (This leg only exists on macOS because tap.python~ needs the embedded Python runtime to configure; the Linux-based Max repos keep clang-tidy-18.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRTDgYKY38WH8Bqz17W9k1 --- .github/workflows/style.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index a49adaa..61012de 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -33,8 +33,8 @@ jobs: submodules: recursive - name: Install the embedded Python runtime (needed to configure) run: ./scripts/install-runtime.sh - - name: Install clang-tidy (LLVM 18) - run: brew install llvm@18 + - name: Install clang-tidy (LLVM 20 — parses the macOS SDK libc++) + run: brew install llvm@20 - name: Configure (compile database) # Single-arch: the package otherwise defaults to a universal build # (arm64;x86_64), whose two -arch flags make one compile command emit @@ -43,7 +43,7 @@ jobs: run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_OSX_ARCHITECTURES=arm64 - name: clang-tidy (Max externals' own TUs; min-api and deps excluded) run: | - tidy="$(brew --prefix llvm@18)/bin/clang-tidy" + tidy="$(brew --prefix llvm@20)/bin/clang-tidy" files=$(python3 -c "import json; print('\n'.join(e['file'] for e in json.load(open('build/compile_commands.json')) if '/source/projects/' in e['file']))") fail=0 # Scope diagnostics to this object's OWN headers. A broad From 1ca3b5e72dbc7ed145aec6c53917695c06c1a7eb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 02:47:43 +0000 Subject: [PATCH 6/6] Run the clang-tidy gate on Linux, like the rest of the family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chasing clang-tidy across macOS toolchains was a dead end: llvm@18 couldn't parse the macOS 26 SDK's libc++ (__builtin_ctzg), and llvm@20 couldn't find the SDK's headers at all. The lint only ran on macOS because tap.python~ *configures* with the bundled Python runtime (macOS/Windows only) — but linting needs only headers, not the runtime. Move the clang-tidy job to ubuntu-latest with clang-tidy-18 (matching every other Max repo) and invoke it directly instead of via a compile database: compile the two TUs against min-api and the system Python headers (python3-dev). This removes the entire macOS-SDK-vs-standalone-clang-tidy problem, and — since it's the same Ubuntu + clang-tidy-18 locally and in CI — it is verifiable before pushing (both TUs are clean under --warnings-as-errors=readability-* with the own-headers filter). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRTDgYKY38WH8Bqz17W9k1 --- .github/workflows/style.yml | 50 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 61012de..eef1254 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -4,10 +4,12 @@ name: Tap House Style # TapHouse configs, (2) a clang-format layout check, and (3) clang-tidy naming + # mandatory-braces over the external's own translation units. # -# tap.python~ only configures with the embedded Python runtime present, and that -# runtime installs on macOS/Windows only — so the clang-tidy gate (which needs a -# compile database) runs on macOS. The drift and clang-format checks are -# platform-independent and stay on Linux. +# All three run on Linux with the same clang-tidy-18 as the rest of the family. +# tap.python~ only *configures* (via CMake) with the bundled Python runtime, +# which installs on macOS/Windows only — but linting just needs headers, so the +# clang-tidy step compiles the TUs directly against min-api and the system +# Python headers instead of a compile database. (Running the lint on macOS +# instead means fighting the SDK's libc++ against a standalone clang-tidy.) on: [push, pull_request] jobs: @@ -26,33 +28,29 @@ jobs: run: clang-format-18 --dry-run --Werror $(git ls-files 'source/projects/*.cpp' 'source/projects/*.h') clang-tidy: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: recursive - - name: Install the embedded Python runtime (needed to configure) - run: ./scripts/install-runtime.sh - - name: Install clang-tidy (LLVM 20 — parses the macOS SDK libc++) - run: brew install llvm@20 - - name: Configure (compile database) - # Single-arch: the package otherwise defaults to a universal build - # (arm64;x86_64), whose two -arch flags make one compile command emit - # two compiler jobs — which clang-tidy cannot process ("expected exactly - # one compiler job"). One arch is enough to lint the sources. - run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_OSX_ARCHITECTURES=arm64 - - name: clang-tidy (Max externals' own TUs; min-api and deps excluded) + - name: Install tools + run: sudo apt-get update -q && sudo apt-get install -y -q clang-tidy-18 python3-dev + - name: clang-tidy (this object's own TUs; min-api, CPython, deps excluded) run: | - tidy="$(brew --prefix llvm@20)/bin/clang-tidy" - files=$(python3 -c "import json; print('\n'.join(e['file'] for e in json.load(open('build/compile_commands.json')) if '/source/projects/' in e['file']))") + d=source/projects/tap.python_tilde + msdk=source/min-api/max-sdk-base/c74support + pyinc=$(python3 -c "import sysconfig; print(sysconfig.get_path('include'))") + inc="-Isource/min-api/include -I$msdk -I$msdk/max-includes -I$msdk/msp-includes -I$msdk/jit-includes -I$pyinc -I$d" + # Scope diagnostics to this object's own headers: a path filter over + # source/projects would also match the bundled CPython headers, which + # are reached via …/tap.python_tilde/../../../support/… . + hf='.*/tap\.python_tilde/tap\.python_tilde.*' fail=0 - # Scope diagnostics to this object's OWN headers. A broad - # '.*/source/projects/.*' also matches the bundled CPython headers, - # which the build includes via a relative path that passes back - # through source/projects (…/tap.python_tilde/../../../support/…), - # flooding the run with CPython naming violations. - for f in $files; do - out=$("$tidy" -p build -header-filter='.*/tap\.python_tilde/tap\.python_tilde.*' --warnings-as-errors='readability-*' "$f" 2>/dev/null || true) + check() { # + local f="$1"; shift + out=$(clang-tidy-18 -header-filter="$hf" --warnings-as-errors='readability-*' "$f" -- -std=c++17 $inc "$@" 2>/dev/null || true) if echo "$out" | grep -qE "warning:|error:"; then echo "$out"; fail=1; fi - done + } + check "$d/tap.python_tilde.cpp" -DC74_MIN_API + check "$d/tap.python_tilde_test.cpp" -DC74_MIN_API -DMIN_TEST -Isource/min-api/test -DCATCH_CONFIG_NO_CPP17_UNCAUGHT_EXCEPTIONS [ "$fail" -eq 0 ] && echo "clang-tidy clean." || { echo "::error::clang-tidy found violations"; exit 1; }