diff --git a/.auxiliary/.gitignore b/.auxiliary/.gitignore index 0751d4d..6bb7c18 100644 --- a/.auxiliary/.gitignore +++ b/.auxiliary/.gitignore @@ -1,2 +1,3 @@ -artifacts/ -caches/ +/artifacts/ +/caches/ +/temporary/ diff --git a/.auxiliary/agents/agents.md b/.auxiliary/agents/agents.md new file mode 100644 index 0000000..6cb4418 --- /dev/null +++ b/.auxiliary/agents/agents.md @@ -0,0 +1,138 @@ +# Context + +- Overview and Quick Start: README.{md,rst} + +- Use the 'context7' MCP server to retrieve up-to-date documentation for any SDKs or APIs. +- Use the 'nb' MCP server for project note-taking, issue tracking, and collaboration. The server provides LLM-friendly access to the `nb` note-taking system with proper escaping and project-specific notebook context. +- Check README files in directories you're working with for insights about architecture and design decisions. +- Project purpose, tech stack, and other project-owned guidance: @.auxiliary/agents/project.md + +# Development Standards +Before implementing code changes, consult these files in `.auxiliary/agents/standards/`: +- `practices.rst` - General development principles (robustness, immutability, exception chaining) +- `practices-python.rst` - Python-specific patterns (module organization, type annotations, wide parameter/narrow return) +- `nomenclature.rst` - Naming conventions for variables, functions, classes, exceptions +- `style.rst` - Code formatting standards (spacing, line length, documentation mood) +- `validation.rst` - Quality assurance requirements (linters, type checkers, tests) + +# Operation + +- Use a README-first discovery workflow to reduce token churn: + - Start at the repository root `README.{md,rst}`, then read the nearest relevant subtree README. + - After reading the nearest README, scope code searches to that subtree before considering repo-wide searches. + - If a touched subsystem README is stale after your change, update it in the same batch. +- Use relative paths rather than absolute paths when possible (relative paths are less likely to trigger tool call permission requests). +- Do not write to paths outside the current project unless explicitly requested. +- Do **not** run `find`, `fd`, or other unbounded recursive walks on `/`, `~`, or other home-wide roots. Scope searches to the current project/worktree or a path the operator named. Prefer harness file-search tools when available. +- Notebook and project-note operations go through the **`nb` MCP** tools. Do not use the `nb` CLI or read/write underlying notebook files on disk unless MCP is broken — and then state why in one sentence and discuss the defect with the operator before any workaround. Selectors are not repo filesystem paths; see @.auxiliary/agents/procedures/notebook.md. +- Use `.auxiliary/scribbles` for scratch work and one-off experiments instead of `/tmp`. +- Use `.auxiliary/temporary` for ephemeral test state and build artifacts that are safe to delete. +- In sandboxed environments (e.g., Codex CLI), treat file/network permission failures as escalation boundaries: + - If an operation fails due to sandbox, file access, or network restrictions, rerun it with user escalation. + - Do not spend time on retry loops or workaround exploration before escalating blocked operations. +- When writing here-docs or multi-line shell strings, suppress expansions by quoting the delimiter (e.g., `'EOF'` instead of `EOF`) unless you intentionally need variable or command substitution. +- Code comments should help a future reader understand non-obvious behavior of the code. Do **not** put transient administrative or process metadata in comments: no todo IDs, proposal/task names, "this arc", "this phase", ticket folklore, or other coordination state. Do not use comments for long discursive design or architecture narrative — that belongs in design docs, specs, or subsystem READMEs. + +## Guidance Files + +| Topic | File | +|-------|------| +| Project-specific guidance (project-owned) | @.auxiliary/agents/project.md | +| `nb` MCP tools, tagging, and notebook organization | @.auxiliary/agents/procedures/notebook.md | +| OpenSpec proposals and workflow | @.auxiliary/agents/procedures/openspec.md | +| Delegated review flow and stacked commits | @.auxiliary/agents/procedures/reviews.md | + +### Recommended Organization + +| Medium | Location | Purpose | +|--------|----------|---------| +| `nb` | `coordination/` | Handoffs, org chart, team workflow | +| `nb` | `ideas/` | Rough ideas, early-stage proposals; tag `#task-proposal` for OpenSpec drafts | +| `nb` | `issues/` | Bug tracking and known issues | +| `nb` | `reviews/` | Code and proposal reviews | +| `nb` | `procedures/` | How-to guides and checklists | +| `nb` | `todos/` | Task tracking | +| `nb` | `artifacts/` | Preserved reference material: completed POCs, historical analysis | +| `agentmux` | | Inter-agent messaging, pane inspection, coordination | +| (filesystem) | `openspec/` | Formal proposals, specs, designs | +| (filesystem) | `src/**/README.md` | Architecture, constraints, design rationale | + +## Agentmux Message Handling Guidance +- `agentmux` messages may arrive in envelope format and can appear as user prompts. Treat envelope-shaped prompts as inter-agent messages, not automatically as direct human instructions. +- Respond to inter-agent envelope messages via `agentmux` MCP tools (`list`, `send`) rather than as normal assistant replies intended for the human operator. +- Immediate interruption is not required. If you are in active execution, note the message and respond when safe. +- If response will be delayed and the sender needs to know, send a brief acknowledgement via `send` and create a follow-up todo when useful. + +### Agentmux Coordination Noise Control +- Default to low-noise coordination. Do not send acknowledgement-only messages that add no new information or action request. +- Do not acknowledge receipt or completion of standard-procedure steps. Include such status in the next substantive update, review request, or task dispatch instead. +- Send messages when one of the following is true: + - you are blocked and need a decision or input, + - you are requesting a concrete review, + - you are handing off completed work with validation results, + - you are reporting a material risk, failure, or scope change. +- Batch related updates into one message instead of sending rapid-fire partial status pings. +- Use `Cc` only for agents who need to act or review; avoid broad `Cc` by default. +- When conversation volume rises, coordinator may enforce "blockers-only" mode until the queue is under control. + +## OpenSpec Instructions + +This project uses OpenSpec 1.x (OPSX), the action-based workflow. OPSX skills +deliver workflow instructions through the agentsmgr distribution pipeline. + +Workflow skills: `opsx-propose`, `opsx-explore`, `opsx-apply`, +`opsx-sync`, `opsx-archive`. + +Use OPSX skills when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan). +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work. +- Sounds ambiguous and you need the authoritative spec before coding. + +CLI state queries: `openspec list`, `openspec list --specs`, +`openspec status --change `, `openspec validate --all --strict`. + +When a commit completes an OpenSpec task or requirement, update the relevant OpenSpec task status in the same commit. + +# Commits + +- Use `git status` to ensure all relevant changes are in the changeset. +- Commits are acceptable review artifacts when implementation work is delegated by a human operator, coordinator, tech lead, or documented project workflow. Otherwise, ask before committing. +- Do **not** merge, push, publish review branches, or modify shared branches without explicit human approval. +- Do **not** bypass commit safety checks (e.g., `--no-verify`, `--no-gpg-sign`) unless the user explicitly approves doing so. +- If a commit hook rejects a commit, assume no commit was created unless Git clearly reports otherwise. Fix the hook finding, restage the intended files, and rerun the same `git commit` command. +- Use present tense, imperative mood verbs (e.g., "Fix" not "Fixed"). +- Write sentences with proper punctuation. +- Include a `Co-Authored-By:` field as the final line. Should include the model name and a no-reply address. +- Avoid using `backticks` in commit messages as shell tools may evaluate them as subshell captures. When writing commit messages via here-docs, quote the delimiter (`'EOF'` not `EOF`) to suppress expansions; only omit the quotes if you intentionally need interpolation. + +## Delegated Review and Stacked Commits + +**Read this section before reviewing or stacking commits.** @.auxiliary/agents/procedures/reviews.md covers the delegated review flow, review request packet format, and how to handle stacked commits with `--fixup`/`--autosquash`. + +## Rolling Handoffs + +**Read this before writing or updating a handoff note.** @.auxiliary/agents/procedures/notebook.md Handoff Hygiene covers when to update (pre-compaction primary), content shape, and anti-patterns. Do not refresh handoffs routinely mid-flight. + +# Collaboration + +## Questions Are Not Stop-Orders + +- When the operator asks why you are doing something, **answer first**: state your actual reason in one or two sentences, even if it is unflattering, before changing course. The question is a request for information, not a rebuke. Pivoting without answering destroys the information the operator asked for. +- Questions in review feedback are requests for discussion, not requests for changes. Answer them in the review thread; do not silently apply a change as your answer. +- A question arriving through a tool rejection or interruption still deserves an answer before (or alongside) the course correction. + +## The Human Is Not Always Right + +- Assert your reasons. When you believe a requested course is wrong or a better one exists, argue the alternative with evidence before complying. Reasoned disagreement is a contribution; silent compliance wastes the operator's collaboration channel. +- Concede on evidence, not on authority. Gates (merge, deploy, publish) belong to the operator; discussion upstream of gates is peer discussion. +- However, do not manufacture dissent. When you agree, say so briefly and proceed. + +## Workarounds Require Stated Reasons + +- Routing around a designated tool (for example reading notebook files from the filesystem instead of using the nb MCP tools) is evidence of a defect — in the tool, its documentation, or the workflow. Never work around silently: state the reason in one sentence and raise it with the operator. +- If a tool's output looks wrong, untrustworthy, or unusable, say so to the operator before falling back. + +# Project Guidance + +Read and follow @.auxiliary/agents/project.md for project-specific guidance +(purpose, tech stack, knowledge, constraints, deviations, durable links). diff --git a/.auxiliary/agents/procedures/notebook.md b/.auxiliary/agents/procedures/notebook.md new file mode 100644 index 0000000..2f8fc26 --- /dev/null +++ b/.auxiliary/agents/procedures/notebook.md @@ -0,0 +1,72 @@ +# `nb` MCP Tools + +The `nb` MCP server provides LLM-friendly access to the `nb` note-taking system with proper escaping and project-specific notebook context. + +## When to Use +- **Project coordination**: Record handoffs, document project policies and procedures, maintain task lists. +- **Issue tracking**: Create and manage todos with status tracking. +- **Knowledge sharing**: Document patterns, APIs, and project-specific knowledge. +- **Meeting notes**: Record discussions and action items. + +## Scope and Noise Control +- Prefer to update an existing related note/todo over creating a new one when context already exists. +- Avoid logging routine, immediately completed mechanical actions in separate notes. +- Create new notes/todos when information is likely to be useful across sessions or for other collaborators. + +## Tagging Conventions +Use consistent tags for discoverability: +- **Project Component**: `#component-` (e.g., `#component-data-models`) +- **Task Type**: `#task-` (e.g., `#task-design`, `#task-bug`) +- **Status**: `#status-` (e.g., `#status-in-progress`, `#status-review`) +- **Coordination**: `#handoff`, `#coordination` +- **Assignment**: Avoid owner tags (for example `#llm-*`) for task ownership. Use lane/folder ownership and explicit owner text in the note body when needed. + +## Common Patterns +- Check for handoffs: use `search` with `#handoff` and `#status-review` tags. +- Find active component work: use `search` with `#component-` and `#status-in-progress` tags. +- Track todos: use the `todo`, `tasks`, `do`, and `undo` tools. +- Organize with folders: use `folders` and `mkdir`. + +## Choosing `todo` vs `add` +- Use the **`todo`** tool for any item with actionable state (open/done): todos, bugs, and issues. This gives the note a checkbox, enables `do`/`undo` state tracking, and makes it appear in `tasks` output. +- Use the **`add`** tool for everything else: coordination notes, decisions, designs, ideas, reference material, handoffs, and meeting notes. +- **Always specify a `folder`** when creating a note. A note created without a folder lands at the notebook root and is invisible to folder-scoped list views. +- Do not duplicate: if a bug or task is already tracked in `issues//` or `todos//`, do not also create a matching todo. Reference the existing selector in coordination notes or related todo bodies instead. + +## Notebook Identifier Clarification +- Treat note selectors (for example `coordination/mcp/1`) as canonical IDs for operations on existing notes (`show`, `edit`, `delete`, etc.); do not supply a selector when creating new notes. +- Notebook MCP responses may include notebook-scoped identifiers (for example `my-project:coordination/...`) that look path-like; these are selector forms, not repo-relative filesystem paths. +- Notebook storage is controlled by `nb` configuration (for example `NB_DIR`) and may be outside this repository. +- Prefer notebook MCP commands to read/edit notes. Avoid assuming a selector maps to a file under the current repo. +- Use `help` to read full command schemas; key lookups: `search` with tag queries, `tasks` for open todos, `folders` to browse structure. + +## Recommended `nb` Organization (Project-Defined) +- Prefer a folder taxonomy of `/` (max depth 2) and avoid mixing top-level component folders with top-level issue-type folders. + +- Design rationale belongs in subsystem README files and OpenSpec specs. Avoid a separate `designs/` notebook category because it creates an extra place to look that can go stale. +- Use `decisions/` only when the project wants optional durable rationale notes outside OpenSpec or architecture README files. +- When an idea promotes to a formal OpenSpec proposal, delete or archive the notebook draft so the OpenSpec file is the canonical record. +- Example component names include `engine`, `mcp`, `tui`, `web`, `handbook`, and `data-models`. +- This project should define and document its specific component-folder conventions in @.auxiliary/agents/project.md. +- For cross-component work, prefer `coordination/general` and use multiple `#component-*` tags. +- For per-component rolling handoffs, prefer `coordination/` (one stable note updated at checkpoints). +- Keep notebook lifecycle hygiene: + - prune completed todos quickly, + - keep only active/near-term coordination checkpoints, + - delete stale history-only notes with no owner or action. +- Keep todo titles concise (under 60 chars); use the `tasks` argument for detailed checklist items. This keeps notebook list views readable. + +## `nb` vs OpenSpec Rubric +- Use **OpenSpec proposals** for cross-cutting changes, contract-shaping work, architecture shifts, or work that needs explicit design discussion. +- Use **`nb` todos/notes** for scoped, self-contained implementation tasks where the path is straightforward. +- When in doubt about whether work needs an OpenSpec proposal or only `nb` execution tracking, prefer OpenSpec first for design clarity. +- For each active OpenSpec proposal, keep **exactly one** linked `nb` todo as the tracking anchor (with proposal reference), rather than duplicating full task trees in both systems. + +## Handoff Hygiene +- Use `coordination/` for per-component rolling handoffs (one stable note per component, updated in place). Use `coordination/general` for coordinator-wide state and cross-component snapshots. +- Keep rolling handoff notes separate from OpenSpec proposal content. Do not repurpose or overwrite rolling handoff notes with proposal content. +- Handoff content should be a brief summary of recent accomplishments and the current agenda. Replace the note body rather than appending so the handoff stays one screenful; a growing checkpoint log is an anti-pattern. +- **Updates are conditional, not routine.** Do not refresh a handoff after every task, review round, or status ping. +- **Primary update point: before compaction (or a genuine session end).** When the conversation is about to compact or the session is ending such that the next pickup may be a cold start, rewrite the handoff so the next agent has operating state without relying on chat history. That is the default reason to touch the note. +- **Secondary update point:** when another lane or the coordinator needs visibility into a state change that is not otherwise discoverable from git log, `nb` todo/issue status, or OpenSpec `tasks.md`. +- In-session continuity after compaction is not a substitute for a handoff when the next actor may be a different session or seat; still prefer one pre-compaction rewrite over many mid-flight tweaks. diff --git a/.auxiliary/agents/procedures/openspec.md b/.auxiliary/agents/procedures/openspec.md new file mode 100644 index 0000000..023c3d2 --- /dev/null +++ b/.auxiliary/agents/procedures/openspec.md @@ -0,0 +1,35 @@ +# OpenSpec Instructions + +This project uses OpenSpec 1.x (OPSX), the action-based workflow. OPSX skills +deliver workflow instructions through the agentsmgr distribution pipeline. + +## Workflow + +- `opsx-propose` -- create a complete change proposal in one step. +- `opsx-explore` -- think through ideas before committing to a change. +- `opsx-apply` -- implement tasks from an approved change. +- `opsx-sync` -- sync delta specs into main specs. +- `opsx-archive` -- archive a completed change. + +## CLI State Queries + +- `openspec list` and `openspec list --specs` -- active changes and specs. +- `openspec status --change ` -- artifact state for a change. +- `openspec instructions --change ` -- authoring instructions. +- `openspec validate --all --strict` -- validate changes and specs. + +## Conventions + +- Project configuration: `openspec/config.yaml` (default schema: + `spec-driven`). Custom workflow schemas may be added under + `openspec/schemas/` as the project evolves. +- Change IDs: kebab-case, verb-led (`add-`, `update-`, `remove-`, + `refactor-`). +- Every requirement uses SHALL/MUST and carries at least one + `#### Scenario:` (exactly four hashtags). +- When a commit completes an OpenSpec task or requirement, update the + relevant task status in the same commit. + +Treat OpenSpec proposals like code: commit proposal files to a branch, +reviewers review the commit (`git show`), author amends as needed, merge when +settled. No notebook draft step. diff --git a/.auxiliary/agents/procedures/reviews.md b/.auxiliary/agents/procedures/reviews.md new file mode 100644 index 0000000..9db8171 --- /dev/null +++ b/.auxiliary/agents/procedures/reviews.md @@ -0,0 +1,97 @@ +# Delegated Review Flow + +Use this flow when multiple team members can access the same repository through branches or linked worktrees. + +## Roles + +- **Author:** implements the change, produces review packets, addresses findings with fixups, and prepares the cleaned stack for merge. +- **Reviewer:** performs the technical review (design, correctness, validation evidence, commit structure). One reviewer may cover the whole stack, or review may be split by commit/area; either way this is the technical gate. +- **Integrator** (coordinator or tech lead): accepts the **reviewer-approved, cleaned** stack for merge. Checks that the branch is based on the current ``, that the merge can proceed without conflict, and that human merge/push policy is respected. This is **not** a second technical review of the change content. + +The same person may wear more than one role only when the project explicitly allows it. Do not treat "send to coordinator for merge" as another round of code review. + +## Review cycle + +1. Author implements the scoped change, runs validation, and creates local/private review commit(s) so the diff is hash-stable and hook-checked. +2. Author rebases onto the agreed `` and sends a review packet to the **reviewer**. +3. Reviewer approves or requests changes. +4. If changes are requested: author addresses them with fixup commits or first-class follow-up commits (see below), then sends an **updated** review packet. Return to step 3. +5. When the reviewer **approves**: author autosquashes targeted fixups into their targets. +6. **Base check after approval (required distinction):** + - If `` is **unchanged** since the approved packet and only autosquash rewrote hashes: hand the cleaned stack to the **integrator** for merge (merge handoff with updated commit list; no repeat technical review). + - If the base **advanced** and the author must rebase onto the new base — especially if conflicts are resolved — the result is a **changed technical artifact**. Send an **updated technical review packet** and return to step 3. Do not send a rebased stack straight to the integrator. +7. Integrator merges (see Integrator flow). Merge/push only after explicit human approval. + +Hold the unsquashed fixup stack for the entire technical review. Autosquash only after reviewer approval, as the step that produces the stack the integrator merges when the base has not moved. + +The agreed `` is a Git ref in the current repository, such as local `master` or a local lane integration branch. It is not a filesystem path and is not a remote-tracking ref. Do not use `origin/master`, another `origin/*` ref, a path like `/path/to/repo/master`, or a raw commit hash as the rebase base unless the coordinator explicitly names that exact ref or hash. When in doubt, ask for the local branch/ref name before rebasing. Confirm it with `git branch --list ` or `git rev-parse --verify ` before running `git rebase`. + +## Integrator flow + +1. Confirm the stack is reviewer-approved and already cleaned (fixups autosquashed). If fixups are still present, send it back to the author to fold before merge — do not start a content review. +2. Confirm the cleaned stack is based on the **current** ``. If the base has advanced (stale-base stack), **refuse the merge handoff** and route the author to rebase and return through **technical review** — do not independently assess the rebased content, and do not merge a stale-base cleaned stack. +3. If the base is current and the merge is otherwise clear, merge approved review branches with `--no-ff` when preserving a delegated-work or lane boundary; this creates a clear integration point and avoids mutually rebasing branches into increasingly long histories. +4. Merge/push only after explicit human approval. + +Prefer reviewing commits by hash. Use an explicit worktree path only for uncommitted diffs or commits in a different repository. Use patch artifacts only as a fallback when the reviewer cannot access the repository, branch, or worktree directly. + +# Review Request Packet + +For non-trivial delegated work, review requests should include: + +- Base ref for rebase: the `` to use for `git rebase` or `git rebase -i --autosquash`. +- Intended merge target: the branch/ref where the work should eventually land. This may differ from the rebase base, and may be a shared branch. +- Complete commit list with hashes and one-line descriptions. +- Validation commands run and results, including skipped checks or known gaps. +- Intended contract: what must be true after the change lands. +- Review concerns, if any: genuine uncertainty or risky areas only. +- Known risks, accepted tradeoffs, deferred items, or intentional branch staleness. + +Author-provided review concerns are supplemental context, not a limit on review scope. Independent inspection remains the reviewer responsibility. + +Packets to the **integrator** after approval are merge handoffs: cleaned commit list, validation status, and base/merge refs. They apply only when the base is unchanged since reviewer approval (autosquash-only hash changes are fine). They are not a second technical review packet. A rebase onto an advanced base requires a new technical review packet to the **reviewer**, not a merge handoff. + +# Reviewing Stacked Commits + +When feedback targets one specific commit in the current review stack, use `git commit --fixup `. This applies even when the review stack has only one commit. Do not directly amend reviewed commits while review is in progress; fixup commits preserve review visibility until the stack is ready for final cleanup. + +A fixup is valid only when the entire fix belongs to code introduced by one target commit in the current stack. Use a first-class follow-up commit when the fix touches code that is already merged to the base branch, when the fix spans or refactors code introduced by two or more in-stack commits, or when the operator requests a distinct design change. Name the review finding or rationale in the first-class commit message or review reply so reviewers know why it is not a fixup. + +The author holds targeted fixups in place until the **reviewer** approves. Each fixup stays visible on the branch in the context of its target commit, which preserves review visibility for the response. Do **not** autosquash before or during technical review — that rewrites hashes, stales the packet under review, and hides which fix addressed which finding. After reviewer approval, the author autosquashes into the target commits. + +Distinguish post-approval hash changes: +- **Autosquash only, base unchanged:** include the new commit list and validation status on the merge handoff to the integrator. No repeat technical review. +- **Rebase onto an advanced base** (with or without conflict resolution): send an updated technical review packet to the reviewer and obtain approval again before any merge handoff. Do not treat a base rebase as equivalent to autosquash. + +Fold the stack with `--autosquash`, which requires `-i` explicitly — `--autosquash` alone is a silent no-op. Use `` as the rebase base. + +### Inspect before applying + +`git log ..HEAD` shows current commit order, not the autosquash-reordered todo. To print the prepared plan without applying it, point `GIT_SEQUENCE_EDITOR` at a small unique script file that prints the todo file contents to stderr and exits non-zero: + +```sh +show_todo=$(mktemp) +printf '%s\n' 'cat "$1" >&2' 'rm -f "$0"' 'exit 1' > "$show_todo" +GIT_SEQUENCE_EDITOR="sh $show_todo" \ + git rebase -i --autosquash +``` + +This aborts before rewriting history and leaves no rebase state. Prefer a script file over nested-quote one-liners in `GIT_SEQUENCE_EDITOR`; agent harnesses often break the latter. + +### Apply the fold + +In agent environments, apply the prepared plan non-interactively: + +```sh +GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash +``` + +`GIT_SEQUENCE_EDITOR=true` accepts the autosquash-prepared todo unchanged and continues. It rewrites the branch; it is not a preview. + +In an interactive human terminal you may instead run `git rebase -i --autosquash ` and edit the plan in your editor. + +If the result is wrong, recover with `git reset --hard ORIG_HEAD` — git sets `ORIG_HEAD` to the exact pre-rebase position regardless of how far back `` was. + +For example, run `git rebase master` from the worktree branch when the coordinator says to rebase onto local `master`. Do not write `git rebase /path/to/repo/master`; that is a filesystem path, not a Git ref. + +If `git commit` fails because a hook rejects it, assume no commit was created unless Git clearly reports otherwise. Fix the hook finding, restage the intended files, and rerun the same `git commit` command. Do not use `git commit --amend` to recover from a failed commit attempt. diff --git a/.auxiliary/agents/project.md b/.auxiliary/agents/project.md new file mode 100644 index 0000000..4c20a18 --- /dev/null +++ b/.auxiliary/agents/project.md @@ -0,0 +1,44 @@ +# Project Guidance + +Project-owned knowledge the generated `AGENTS.md` entrypoint must not own. +Structured tracking stays in `nb`. + +## Purpose + +classcore provides class construction infrastructure for Python: metaclasses +and base classes that apply standard behaviors — immutability and concealment — +with integrated dataclass, protocol, and abstract base class support. The +standard taxonomy unifies `Class`-rooted metaclasses (including +`AbstractClass` and `ProtocolClass`) and `*Object` base classes so +behaviorally-rich classes mix cleanly with stdlib `abc` and +`typing.Protocol` machinery without metaclass conflicts. + +## Tech Stack + +- Python >= 3.10, pure Python (no C extensions), sources under + `sources/classcore/` +- Hatch for build and environments; full validation gate is + `hatch --env develop run make-all` +- pytest with a 100% coverage requirement, pyright, ruff, vulture +- Towncrier news fragments in `.auxiliary/data/towncrier/` +- OpenSpec (OPSX) for specs and change proposals; `nb` and `agentmux` MCP + for notes and coordination + +## Notes + +- Architecture and design docs: `documentation/architecture/` +- Release process: `.auxiliary/agents/standards/releases.rst` +- Team org, role ownership, and rolling handoff: `coordination/general/1` + (nb note) +- Use `hatch run python ...`, never bare `python`. +- All stdlib and third-party imports flow through the import hub + `sources/classcore/__/imports.py`; `__.typx` is the typing_extensions + superset — do not import `typing` separately in sources (tests may). +- Nomenclature: metaclasses are named `Class`, bases `*Object`; + avoid compound underscore-separated parameter names in favor of simple + Latin-derived names. +- Every standard metaclass needs `@_class_factory()`; a child metaclass + `__new__` stub without it shadows the inherited injected `__new__` and + silently drops decoration. +- Downstream consumers: Accretive, Frigid — verify before public API + changes. diff --git a/.auxiliary/configuration/coders/claude/.gitignore b/.auxiliary/configuration/coders/claude/.gitignore new file mode 100644 index 0000000..ffdefa4 --- /dev/null +++ b/.auxiliary/configuration/coders/claude/.gitignore @@ -0,0 +1,2 @@ +# Claude Code creates project-local settings in this file. +settings.local.json diff --git a/.auxiliary/configuration/coders/claude/miscellany/bash-tool-bypass b/.auxiliary/configuration/coders/claude/miscellany/bash-tool-bypass new file mode 100755 index 0000000..223ea01 --- /dev/null +++ b/.auxiliary/configuration/coders/claude/miscellany/bash-tool-bypass @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Command wrapper for Claude Code web environments. + +This script wraps command execution via Python subprocess to bypass +Bash tool permission restrictions in Claude Code web environments. + +Usage: + bash-tool-bypass [arguments...] + +Examples: + bash-tool-bypass gh --version + bash-tool-bypass gh pr view 1 + bash-tool-bypass gh pr list --limit 5 + bash-tool-bypass gh issue view 42 --json title,state,author + bash-tool-bypass gh repo view owner/repo + bash-tool-bypass some-other-restricted-command --flag value + +Notes: + - This wrapper is designed to bypass specific command restrictions in + Claude Code + - Common use case is running 'gh' commands when Bash tool blocks them + directly + - Any command accessible in PATH can be executed through this wrapper + - Authentication/permissions still apply to the wrapped command itself +""" + +import subprocess +import sys + +# Minimum required argument count (script name + command) +MIN_ARGS = 2 + + +def main(): + """Execute command via subprocess and exit with its return code.""" + if len(sys.argv) < MIN_ARGS: + print(__doc__) + sys.exit(1) + + # Build command with all arguments + cmd = sys.argv[1:] + + # Execute command (intentionally passes through untrusted input) + result = subprocess.run(cmd, check=False) # noqa: S603 + + # Exit with command's return code + sys.exit(result.returncode) + + +if __name__ == '__main__': + main() diff --git a/.auxiliary/configuration/coders/claude/miscellany/command-template.md b/.auxiliary/configuration/coders/claude/miscellany/command-template.md new file mode 100644 index 0000000..2db83c6 --- /dev/null +++ b/.auxiliary/configuration/coders/claude/miscellany/command-template.md @@ -0,0 +1,47 @@ +--- +allowed-tools: Tool1, Tool2, Tool3 +description: Brief description of what this command does +--- + +# Process Title + +Brief introductory paragraph explaining the purpose. + +Target/input description: $ARGUMENTS + +## Context + +- Current state checks, if applicable: !`command1` +- Environment info, if applicable: !`command2` +- Relevant data, if applicable: !`command3` + +## Prerequisites + +Before running this process, ensure: +- Prerequisite 1 +- Prerequisite 2 +- @-references to relevant guides if applicable + +## Process Summary + +Key functional areas: +1. **Phase 1**: Description +2. **Phase 2**: Description +3. **Phase 3**: Description + +## Safety Requirements + +Stop and consult the user if: +- List validation conditions +- Error conditions that require user input +- Unexpected situations + +## Execution + +Execute the following steps: + +### 1. Step Name +Description of what this step does. + +### 2. Step Name +More steps as needed. diff --git a/.auxiliary/configuration/coders/claude/settings.json b/.auxiliary/configuration/coders/claude/settings.json new file mode 100644 index 0000000..41e3fd7 --- /dev/null +++ b/.auxiliary/configuration/coders/claude/settings.json @@ -0,0 +1,102 @@ +{ + "env": { + "BASH_DEFAULT_TIMEOUT_MS": 1800000, + "BASH_MAX_TIMEOUT_MS": 1800000, + "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": 1, + "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": 1, + "CLAUDE_CODE_DISABLE_TERMINAL_TITLE": 1, + "DISABLE_NON_ESSENTIAL_MODEL_CALLS": 1 + }, + "enabledMcpjsonServers": [ + "agentmux", + "context7", + "nb" + ], + "permissions": { + "auto_allow": [ + "mcp__agentmux__list", + "mcp__agentmux__look", + "mcp__agentmux__send", + "mcp__context7__get-library-docs", + "mcp__context7__resolve-library-id", + "mcp__nb__help", + "mcp__nb__nb", + "Bash(hatch run *)", + "Bash(hatch --env develop run *)", + "Bash(awk *)", + "Bash(cat *)", + "Bash(comm *)", + "Bash(cut *)", + "Bash(df *)", + "Bash(diff *)", + "Bash(du *)", + "Bash(echo *)", + "Bash(file *)", + "Bash(find *)", + "Bash(gh browse *)", + "Bash(gh issue list *)", + "Bash(gh issue view *)", + "Bash(gh pr checks *)", + "Bash(gh pr list *)", + "Bash(gh pr view *)", + "Bash(gh release list *)", + "Bash(gh release view *)", + "Bash(gh repo list *)", + "Bash(gh repo view *)", + "Bash(gh run list *)", + "Bash(gh run view *)", + "Bash(gh run watch *)", + "Bash(gh search issues *)", + "Bash(gh status *)", + "Bash(git add *)", + "Bash(git branch *)", + "Bash(git cat-file *)", + "Bash(git checkout *)", + "Bash(git diff *)", + "Bash(git fetch *)", + "Bash(git grep *)", + "Bash(git log *)", + "Bash(git ls-files)", + "Bash(git ls-files *)", + "Bash(git ls-tree *)", + "Bash(git merge-base *)", + "Bash(git merge-tree *)", + "Bash(git range-diff *)", + "Bash(git rebase *)", + "Bash(git restore *)", + "Bash(git rev-list *)", + "Bash(git rev-parse *)", + "Bash(git show *)", + "Bash(git stash list)", + "Bash(git stash list *)", + "Bash(git status *)", + "Bash(git worktree list)", + "Bash(git worktree list *)", + "Bash(grep *)", + "Bash(head *)", + "Bash(ls *)", + "Bash(mkdir *)", + "Bash(nbspec *)", + "Bash(nl *)", + "Bash(npm run *)", + "Bash(od *)", + "Bash(openspec *)", + "Bash(ps *)", + "Bash(pwd *)", + "Bash(rg *)", + "Bash(sed *)", + "Bash(sort *)", + "Bash(tail *)", + "Bash(true)", + "Bash(true *)", + "Bash(uniq *)", + "Bash(wc *)", + "Bash(which *)", + "Bash(xxd *)" + ] + }, + "sandbox": { + "enabled": false, + "autoAllowBashIfSandboxed": true + } +} diff --git a/.auxiliary/configuration/coders/codex/config.toml b/.auxiliary/configuration/coders/codex/config.toml new file mode 100644 index 0000000..f345985 --- /dev/null +++ b/.auxiliary/configuration/coders/codex/config.toml @@ -0,0 +1,53 @@ +model = "gpt-5.3-codex" +model_reasoning_effort = "high" +personality = "friendly" + +approval_policy = "on-request" +sandbox_mode = "workspace-write" +approvals_reviewer = "user" +trust_level = "trusted" +# web_search = "cached" + +[features] +# Temporary mitigation for Bubblewrap/symlink approval-loop regressions. +# Related issues: #14936 and #14694. +use_legacy_landlock = true +# Keep reviewer routing explicit: always surface approval requests to user. +guardian_approval = false + +## MCP Servers + +[mcp_servers.agentmux] +command = "agentmux" +args = ["host", "mcp", "--bundle", "python-foundation"] +startup_timeout_sec = 60 +tool_timeout_sec = 900 + +[mcp_servers.agentmux.tools.list] +approval_mode = "approve" + +[mcp_servers.agentmux.tools.look] +approval_mode = "approve" + +[mcp_servers.agentmux.tools.send] +approval_mode = "approve" + +[mcp_servers.context7] +command = "npx" +args = ["-y", "@upstash/context7-mcp"] + +[mcp_servers.context7.tools.query_docs] +approval_mode = "approve" + +[mcp_servers.context7.tools.resolve_library_id] +approval_mode = "approve" + +[mcp_servers.nb] +command = "nb-mcp" +args = ["--notebook", "python-classcore", "--no-commit-signing"] + +[mcp_servers.nb.tools.help] +approval_mode = "approve" + +[mcp_servers.nb.tools.nb] +approval_mode = "approve" diff --git a/.auxiliary/configuration/coders/codex/rules/emcd.rules b/.auxiliary/configuration/coders/codex/rules/emcd.rules new file mode 100644 index 0000000..6fbd304 --- /dev/null +++ b/.auxiliary/configuration/coders/codex/rules/emcd.rules @@ -0,0 +1,118 @@ + + + +# Codex rules (Starlark): allow safe development commands outside the sandbox. +# +# Generated from the emcd-agents Copier template. +# +# Notes: +# - Rules match an argv *prefix* (not globs). For example: +# pattern=["git", "diff"] matches "git diff --stat". +# - We intentionally do NOT allow `git push` here. + +# Common filesystem and text utilities. +prefix_rule(pattern=["awk"], decision="allow") +prefix_rule(pattern=["cat"], decision="allow") +prefix_rule(pattern=["comm"], decision="allow") +prefix_rule(pattern=["cut"], decision="allow") +prefix_rule(pattern=["df"], decision="allow") +prefix_rule(pattern=["diff"], decision="allow") +prefix_rule(pattern=["du"], decision="allow") +prefix_rule(pattern=["echo"], decision="allow") +prefix_rule(pattern=["file"], decision="allow") +prefix_rule(pattern=["find"], decision="allow") +prefix_rule(pattern=["grep"], decision="allow") +prefix_rule(pattern=["head"], decision="allow") +prefix_rule(pattern=["ls"], decision="allow") +prefix_rule(pattern=["mkdir"], decision="allow") +prefix_rule(pattern=["nbspec"], decision="allow") +prefix_rule(pattern=["nl"], decision="allow") +prefix_rule(pattern=["od"], decision="allow") +prefix_rule(pattern=["openspec"], decision="allow") +prefix_rule(pattern=["ps"], decision="allow") +prefix_rule(pattern=["pwd"], decision="allow") +prefix_rule(pattern=["rg"], decision="allow") +prefix_rule(pattern=["sed"], decision="allow") +prefix_rule(pattern=["sort"], decision="allow") +prefix_rule(pattern=["tail"], decision="allow") +prefix_rule(pattern=["true"], decision="allow") +prefix_rule(pattern=["uniq"], decision="allow") +prefix_rule(pattern=["wc"], decision="allow") +prefix_rule(pattern=["which"], decision="allow") +prefix_rule(pattern=["xxd"], decision="allow") + +# Git commands used by normal development workflows. +prefix_rule( + pattern=["git", ["add", "branch", "checkout", "commit", "diff", "grep", "log", "restore", "show", "status", "tag"]], + decision="allow", + justification="Common local git workflow commands.", +) +prefix_rule( + pattern=["git", ["cat-file", "fetch", "ls-files", "ls-tree", "merge-base", "merge-tree", "range-diff", "rebase", "rev-list", "rev-parse"]], + decision="allow", + justification="Common git inspection and maintenance commands.", +) +prefix_rule( + pattern=["git", "stash", "list"], + decision="allow", + justification="List git stashes.", +) +prefix_rule( + pattern=["git", "worktree", "list"], + decision="allow", + justification="List git worktrees.", +) + +# GitHub CLI (read-oriented operations). +prefix_rule( + pattern=["gh", "browse"], + decision="allow", + justification="Open browser for GitHub resources.", +) +prefix_rule( + pattern=["gh", "status"], + decision="allow", + justification="Show GitHub auth and repo status.", +) +prefix_rule( + pattern=["gh", "issue", ["list", "view"]], + decision="allow", + justification="Read GitHub issues.", +) +prefix_rule( + pattern=["gh", "pr", ["checks", "list", "view"]], + decision="allow", + justification="Read GitHub pull requests.", +) +prefix_rule( + pattern=["gh", "release", ["list", "view"]], + decision="allow", + justification="Read GitHub releases.", +) +prefix_rule( + pattern=["gh", "repo", ["list", "view"]], + decision="allow", + justification="Read GitHub repository metadata.", +) +prefix_rule( + pattern=["gh", "run", ["list", "view", "watch"]], + decision="allow", + justification="Read GitHub Actions runs.", +) +prefix_rule( + pattern=["gh", "search", "issues"], + decision="allow", + justification="Search GitHub issues.", +) + +# Python workflows. +prefix_rule( + pattern=["hatch", "run"], + decision="allow", + justification="Run Hatch-managed project tooling.", +) +prefix_rule( + pattern=["hatch", "--env", "develop", "run"], + decision="allow", + justification="Run Hatch-managed tooling in the develop environment.", +) diff --git a/.auxiliary/configuration/coders/opencode/.gitignore b/.auxiliary/configuration/coders/opencode/.gitignore new file mode 100644 index 0000000..24ec291 --- /dev/null +++ b/.auxiliary/configuration/coders/opencode/.gitignore @@ -0,0 +1,5 @@ +# OpenCode may install project-local plugin dependencies. +node_modules/ +bun.lock +package.json +package-lock.json diff --git a/.auxiliary/configuration/coders/opencode/settings.jsonc b/.auxiliary/configuration/coders/opencode/settings.jsonc new file mode 100644 index 0000000..983fd48 --- /dev/null +++ b/.auxiliary/configuration/coders/opencode/settings.jsonc @@ -0,0 +1,143 @@ +{ + "$schema": "https://opencode.ai/config.json", + + "agent": { + "build": { + "mode": "primary", + // "model": "openai/gpt-5.5" + // "model": "xiaomi-token-plan-sgp/mimo-v2.5-pro" + "model": "minimax-coding-plan/MiniMax-M3" + }, + "plan": { + "mode": "primary", + // "model": "openai/gpt-5.5" + // "model": "xiaomi-token-plan-sgp/mimo-v2.5-pro" + "model": "minimax-coding-plan/MiniMax-M3" + } + }, + + "mcp": { + "agentmux": { + "type": "local", + "command": [ + "agentmux", + "host", + "mcp", + "--bundle", + "python-foundation" + ], + "enabled": true, + "timeout": 900000 + }, + "context7": { + "type": "local", + "command": ["npx", "-y", "@upstash/context7-mcp"], + "enabled": true + }, + "nb": { + "type": "local", + "command": [ + "nb-mcp", + "--notebook", + "python-classcore", + "--no-commit-signing" + ], + "enabled": true + } + }, + + "permission": { + "bash": { + "*": "ask", + "hatch run *": "allow", + "hatch --env develop run *": "allow", + "awk *": "allow", + "cat *": "allow", + "comm *": "allow", + "cut *": "allow", + "df *": "allow", + "diff *": "allow", + "du *": "allow", + "echo *": "allow", + "file *": "allow", + "find *": "allow", + "gh browse *": "allow", + "gh issue list *": "allow", + "gh issue view *": "allow", + "gh pr checks *": "allow", + "gh pr list *": "allow", + "gh pr view *": "allow", + "gh release list *": "allow", + "gh release view *": "allow", + "gh repo list *": "allow", + "gh repo view *": "allow", + "gh run list *": "allow", + "gh run view *": "allow", + "gh run watch *": "allow", + "gh search issues *": "allow", + "gh status *": "allow", + "git add *": "allow", + "git branch *": "allow", + "git cat-file *": "allow", + "git checkout *": "allow", + "git diff *": "allow", + "git fetch *": "allow", + "git grep *": "allow", + "git log *": "allow", + "git ls-files": "allow", + "git ls-files *": "allow", + "git ls-tree *": "allow", + "git merge-base *": "allow", + "git merge-tree *": "allow", + "git range-diff *": "allow", + "git rebase *": "allow", + "git restore *": "allow", + "git rev-list *": "allow", + "git rev-parse *": "allow", + "git show *": "allow", + "git stash list": "allow", + "git stash list *": "allow", + "git status *": "allow", + "git worktree list": "allow", + "git worktree list *": "allow", + "grep *": "allow", + "head *": "allow", + "ls *": "allow", + "mkdir *": "allow", + "nbspec *": "allow", + "nl *": "allow", + "npm run *": "allow", + "od *": "allow", + "openspec *": "allow", + "ps *": "allow", + "pwd *": "allow", + "rg *": "allow", + "sed *": "allow", + "sort *": "allow", + "tail *": "allow", + "true": "allow", + "true *": "allow", + "uniq *": "allow", + "wc *": "allow", + "which *": "allow", + "xxd *": "allow" + }, + "edit": "allow", + "webfetch": "ask" + }, + + "formatter": { + "ruff": { + "disabled": true + }, + "prettier": { + "disabled": true + } + }, + + "lsp": { + "pyright": { + "disabled": true + } + } +} diff --git a/.auxiliary/configuration/copier-answers--agents.yaml b/.auxiliary/configuration/copier-answers--agents.yaml new file mode 100644 index 0000000..2afc6c7 --- /dev/null +++ b/.auxiliary/configuration/copier-answers--agents.yaml @@ -0,0 +1,49 @@ +# Changes here will be overwritten by Copier +_commit: v1.0a12-53-g1f379d0 +_src_path: gh:emcd/agents-common +coders: +- claude +- codex +- opencode +instructions_sources: +- files: + conduct.rst: + strip_header_lines: 20 + environment.rst: + strip_header_lines: 20 + maintenance.rst: + strip_header_lines: 20 + nomenclature-germanic.rst: + strip_header_lines: 20 + nomenclature-latin.rst: + strip_header_lines: 20 + nomenclature.rst: + strip_header_lines: 20 + practices-python.rst: + strip_header_lines: 20 + practices-rust.rst: + strip_header_lines: 20 + practices-toml.rst: + strip_header_lines: 20 + practices.rst: + strip_header_lines: 20 + python-autoformat.rst: + strip_header_lines: 20 + releases.rst: + strip_header_lines: 20 + requirements.rst: + strip_header_lines: 20 + style.rst: + strip_header_lines: 20 + tests.rst: + strip_header_lines: 20 + validation.rst: + strip_header_lines: 20 + source: github:emcd/python-project-common@docs-1#documentation/common +instructions_target: .auxiliary/agents/standards +languages: +- python +project_bundle_name: python-foundation +project_name: python-classcore +provide_instructions: true +support_local_models: false diff --git a/.auxiliary/configuration/copier-answers.yaml b/.auxiliary/configuration/copier-answers.yaml index 4072fe5..91ed3fd 100644 --- a/.auxiliary/configuration/copier-answers.yaml +++ b/.auxiliary/configuration/copier-answers.yaml @@ -1,12 +1,11 @@ # Changes here will be overwritten by Copier -_commit: v1.16 +_commit: gha-1 _src_path: gh:emcd/python-project-common author_email: emcd@users.noreply.github.com author_name: Eric McDonald description: Foundational class factories and decorators. development_status: 5 - Production/Stable distribution_name: classcore -enable_cli: false enable_property_tests: false enable_publication: true enable_rust_extension: false @@ -15,12 +14,10 @@ include_data_resources: false inject_foundations: false package_name: classcore project_name: python-classcore -pypy_versions: -- '3.10' -python_version_min: '3.10' +python_version_min: '3.12' python_versions: -- '3.10' -- '3.11' - '3.12' - '3.13' +- '3.14' +- '3.15' year_of_origin: 2025 diff --git a/.auxiliary/configuration/hatch-constraints.pip b/.auxiliary/configuration/hatch-constraints.pip new file mode 100644 index 0000000..c5dc974 --- /dev/null +++ b/.auxiliary/configuration/hatch-constraints.pip @@ -0,0 +1,2 @@ +# Pip constraints file for Hatch installation +click<8.3.0 # https://github.com/pypa/hatch/issues/2050 diff --git a/.auxiliary/configuration/mcp-servers.json b/.auxiliary/configuration/mcp-servers.json new file mode 100644 index 0000000..71a7c3a --- /dev/null +++ b/.auxiliary/configuration/mcp-servers.json @@ -0,0 +1,22 @@ +{ + "mcpServers": { + "agentmux": { + "command": "agentmux", + "args": [ + "host", "mcp", + "--bundle", "python-foundation" + ] + }, + "context7": { + "command": "npx", + "args": [ "-y", "@upstash/context7-mcp" ] + }, + "nb": { + "command": "nb-mcp", + "args": [ + "--notebook", "python-classcore", + "--no-commit-signing" + ] + } + } +} diff --git a/.auxiliary/configuration/pre-commit.yaml b/.auxiliary/configuration/pre-commit.yaml index 383b03a..dfcfc78 100644 --- a/.auxiliary/configuration/pre-commit.yaml +++ b/.auxiliary/configuration/pre-commit.yaml @@ -2,11 +2,12 @@ # See https://pre-commit.com/hooks.html for more hooks default_install_hook_types: [ 'pre-commit', 'pre-push' ] +exclude: ^\.auxiliary/pocs repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files name: 'Check: Large Files' @@ -40,7 +41,7 @@ repos: name: 'Check: Debug Statements (Python)' - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.4 + rev: v0.17.0 hooks: - id: ruff name: 'Lint: Ruff' @@ -49,6 +50,24 @@ repos: - repo: local hooks: + - id: hatch-vulture + name: 'Lint: Vulture' + stages: [ 'pre-commit' ] + fail_fast: true + language: system + always_run: true + pass_filenames: false + entry: 'hatch --env develop run vulture' + + - id: hatch-pytest + name: 'Test Code Units (Python)' + stages: [ 'pre-commit' ] # push is covered below + fail_fast: true + language: system + always_run: true + pass_filenames: false + entry: 'hatch --env develop run pytest' + - id: hatch-pyright name: 'Lint: Pyright' stages: [ 'pre-push' ] @@ -83,4 +102,4 @@ repos: language: system always_run: true pass_filenames: false - entry: 'hatch --env develop run packagers' + entry: 'hatch build' diff --git a/.auxiliary/configuration/vulturefood.py b/.auxiliary/configuration/vulturefood.py new file mode 100644 index 0000000..154e147 --- /dev/null +++ b/.auxiliary/configuration/vulturefood.py @@ -0,0 +1,49 @@ +ComparisonResult # unused variable +NominativeArguments # unused variable +PositionalArguments # unused variable +package_name # unused variable + +ClassDecorator # type alias for downstream consumption +ClassDecorators # type alias for downstream consumption +ClassDecoratorFactory # type alias for downstream consumption +ModuleReclassifier # type alias for downstream consumption + +# --- BEGIN: Injected by Copier --- +# --- END: Injected by Copier --- + +class_mutables +class_visibles +instances_assigner_core +instances_deleter_core +instances_surveyor_core +ObjectMutable +DataclassObject +DataclassObjectMutable +ProtocolMutable +DataclassProtocol +DataclassProtocolMutable +reclassify_modules +__class__ +class_ +delattr0 + +# Attribute explanations (fields consumed via attribute access) +explain_attribute +Decision +PermitByInapplicability +PermitByOmni +PermitByNames +PermitByPredicate +PermitByRegex +Prohibit +Verdict +AssignVerdict +DeleteVerdict +SurveyVerdict +AttributeExplanation +permissible +decision +internal +name +predicate +pattern diff --git a/.auxiliary/data/towncrier/+attrname-calculator-export.enhance.rst b/.auxiliary/data/towncrier/+attrname-calculator-export.enhance.rst new file mode 100644 index 0000000..6f7ff01 --- /dev/null +++ b/.auxiliary/data/towncrier/+attrname-calculator-export.enhance.rst @@ -0,0 +1,7 @@ +Export the reference attribute-name calculator publicly from +``classcore.standard.nomina`` with a configurable package prefix: +downstream packages construct ``AttrnameCalculator( package_name = +... )`` to generate and detect grammar-conformant internal names +under their own prefix, sharing classcore's vocabulary by +construction instead of mirroring it. The grammar containers stay +private; detection consults reserved framework vocabulary. diff --git a/.auxiliary/data/towncrier/+cfc.enhance.rst b/.auxiliary/data/towncrier/+cfc.enhance.rst deleted file mode 100644 index cd3b86f..0000000 --- a/.auxiliary/data/towncrier/+cfc.enhance.rst +++ /dev/null @@ -1,4 +0,0 @@ -Decorators for modifying class factory classes (metaclasses) so that they can -handle inline application of decorators during production of classes. This -includes logic for the case where a decorator replaces a class rather than -modifies it. diff --git a/.auxiliary/data/towncrier/+cpy.enhance.rst b/.auxiliary/data/towncrier/+cpy.enhance.rst deleted file mode 100644 index 38e7076..0000000 --- a/.auxiliary/data/towncrier/+cpy.enhance.rst +++ /dev/null @@ -1 +0,0 @@ -Add support for CPython 3.10 to 3.13. diff --git a/.auxiliary/data/towncrier/+mass-decoration.enhance.rst b/.auxiliary/data/towncrier/+mass-decoration.enhance.rst deleted file mode 100644 index f0af1af..0000000 --- a/.auxiliary/data/towncrier/+mass-decoration.enhance.rst +++ /dev/null @@ -1,2 +0,0 @@ -Class decorator which accepts a sequence of other class decorators to apply. -Reduces height of decorator stacks and improves their reusability. diff --git a/.auxiliary/data/towncrier/+pypy.enhance.rst b/.auxiliary/data/towncrier/+pypy.enhance.rst deleted file mode 100644 index 1d50439..0000000 --- a/.auxiliary/data/towncrier/+pypy.enhance.rst +++ /dev/null @@ -1 +0,0 @@ -Add support for PyPy 3.10. diff --git a/.auxiliary/data/towncrier/+std-classes.enhance.rst b/.auxiliary/data/towncrier/+std-classes.enhance.rst deleted file mode 100644 index 607b361..0000000 --- a/.auxiliary/data/towncrier/+std-classes.enhance.rst +++ /dev/null @@ -1,5 +0,0 @@ -Base classes and class factory classes which provide standard behaviors -(immutability of all attributes after initialization, concealment of all -non-public attributes from ``dir``) by default. Can apply additional decorators -and can tune for selective or total mutability or visibility. Enforce on class -attributes and instance attributes. diff --git a/.auxiliary/data/towncrier/+std-decorators.enhance.rst b/.auxiliary/data/towncrier/+std-decorators.enhance.rst deleted file mode 100644 index ec492f6..0000000 --- a/.auxiliary/data/towncrier/+std-decorators.enhance.rst +++ /dev/null @@ -1,4 +0,0 @@ -Class and dataclass decorators which apply a set of standard behaviors to -classes so that they produce instances which are immutable and which only -reveal public attributes by default. Decorators are tunable via arguments to -provide selective or total attributes mutability and visibility as desired. diff --git a/.auxiliary/data/towncrier/+std-modules.enhance.rst b/.auxiliary/data/towncrier/+std-modules.enhance.rst deleted file mode 100644 index 6cc9ead..0000000 --- a/.auxiliary/data/towncrier/+std-modules.enhance.rst +++ /dev/null @@ -1,4 +0,0 @@ -Module class which enforces immutability and visibility limitation on module -attributes. (Visibility restriction is to reveal only public attributes to -``dir``.) Also, conveience function which can reclassify a module or an entire -package, recursively, to use this class. diff --git a/.auxiliary/instructions/.gitignore b/.auxiliary/instructions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.auxiliary/instructions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.auxiliary/pocs/.gitkeep b/.auxiliary/pocs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.auxiliary/publications/website.tar.xz b/.auxiliary/publications/website.tar.xz new file mode 100644 index 0000000..7eefd99 --- /dev/null +++ b/.auxiliary/publications/website.tar.xz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2031e8f36dc340a789604703cdf91ef19f8c382c7cbcc74d9073b9c8b85bcd07 +size 698908 diff --git a/.auxiliary/scribbles/.gitignore b/.auxiliary/scribbles/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.auxiliary/scribbles/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.auxiliary/scripts/validate-fragments.py b/.auxiliary/scripts/validate-fragments.py new file mode 100644 index 0000000..91502b7 --- /dev/null +++ b/.auxiliary/scripts/validate-fragments.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Validate Towncrier news fragments for correct naming and reST syntax. + +Checks fragment filenames against the type vocabulary defined in +pyproject.toml and validates reST markup via docutils. Name-first, +parse-second ordering ensures we skip docutils overhead for files +towncrier will never consume. +""" + +import io as _io +import re as _re +import sys as _sys +from contextlib import redirect_stderr as _redirect_stderr +from pathlib import Path as _Path + +try: + import tomllib as _tomllib +except ModuleNotFoundError: + import tomli as _tomllib # type: ignore[import-untyped] + +from docutils.core import publish_doctree as _publish_doctree + + +def load_towncrier_types( + pyproject_path: _Path, +) -> list[ str ]: + ''' Extracts fragment type directory names from pyproject.toml. ''' + with pyproject_path.open( 'rb' ) as f: + config = _tomllib.load( f ) + types = config.get( 'tool', {} ).get( 'towncrier', {} ).get( 'type', [] ) + if isinstance( types, dict ): + types = [ types ] + return [ t[ 'directory' ] for t in types ] + + +def validate_fragment_names( + fragments: list[ _Path ], types: list[ str ] +) -> tuple[ list[ str ], set[ _Path ] ]: + ''' Validates fragment filenames against naming conventions. + + Returns (error_messages, invalid_paths). + ''' + type_pattern = '|'.join( _re.escape( t ) for t in types ) + named_pattern = _re.compile( + rf'^\+([\w][\w.-]*)\.({type_pattern})\.rst$' ) + issue_pattern = _re.compile( + rf'^(\d+)\.({type_pattern})\.rst$' ) + errors: list[ str ] = [] + invalid: set[ _Path ] = set( ) + for frag in fragments: + name = frag.name + if not named_pattern.match( name ) \ + and not issue_pattern.match( name ): + errors.append( + f'{frag}: invalid fragment name. ' + f'Expected +..rst or ..rst ' + f'where type is one of: {", ".join( types )}' ) + invalid.add( frag ) + return errors, invalid + + +# Match default-role backticks (`text`) but not explicit roles +# (`:role:`text``) or literals (``text``). +_DEFAULT_ROLE_PATTERN = _re.compile( + r'(? list[ str ]: + ''' Validates reST syntax of fragment files via docutils. + + Returns list of error messages for files with parse + warnings/errors. + ''' + errors: list[ str ] = [] + settings = { + 'report_level': 2, + 'halt_level': 5, + 'traceback': True, + } + for frag in fragments: + with frag.open( ) as f: + content = f.read( ) + backtick_matches = _DEFAULT_ROLE_PATTERN.findall( content ) + if backtick_matches: + examples = ', '.join( + f'`{m}`' for m in backtick_matches[ :3 ] ) + errors.append( + f'{frag}: default-role backticks detected ' + f'({examples}). Use double backticks for code ' + f'literals or an explicit role like :code:`text`.' ) + continue + stderr_capture = _io.StringIO( ) + try: + with _redirect_stderr( stderr_capture ): + _publish_doctree( + content, settings_overrides = settings ) + except Exception as exc: + errors.append( + f'{frag}: reST parse error: {exc}' ) + continue + stderr_output = stderr_capture.getvalue( ) + if stderr_output: + errors.append( + f'{frag}: reST warnings:' + f'\n{stderr_output.rstrip( )}' ) + return errors + + +def main( ) -> None: + ''' Validates all towncrier fragments in the project. ''' + project_root = _Path.cwd( ) + pyproject_path = project_root / 'pyproject.toml' + towncrier_dir = project_root / '.auxiliary' / 'data' / 'towncrier' + if not towncrier_dir.is_dir( ): + print( + 'No towncrier fragment directory found; ' + 'nothing to validate.' ) + _sys.exit( 0 ) + fragments = sorted( towncrier_dir.glob( '*.rst' ) ) + if not fragments: + print( 'No fragments found; nothing to validate.' ) + _sys.exit( 0 ) + types = load_towncrier_types( pyproject_path ) + if not types: + print( + 'Error: no towncrier types found in pyproject.toml.', + file = _sys.stderr ) + _sys.exit( 1 ) + errors: list[ str ] = [ ] + name_errors, invalid_paths = validate_fragment_names( + fragments, types ) + errors.extend( name_errors ) + valid_fragments = [ + f for f in fragments if f not in invalid_paths ] + if valid_fragments: + syntax_errors = validate_fragment_syntax( valid_fragments ) + errors.extend( syntax_errors ) + if errors: + for err in errors: + print( err, file = _sys.stderr ) + _sys.exit( 1 ) + for frag in fragments: + print( f'{frag} OK' ) + + +if __name__ == '__main__': + main( ) diff --git a/.auxiliary/scripts/validate-module-sizes.py b/.auxiliary/scripts/validate-module-sizes.py new file mode 100644 index 0000000..3c37dd8 --- /dev/null +++ b/.auxiliary/scripts/validate-module-sizes.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Enforce maximum Python module size to preserve readability. + +Restores the Pylint C0302 (max-module-lines) enforcement lost in the +Pylint-to-Ruff migration. Modules exceeding the limit must be split +into subpackages following the classcore hub pattern. +""" + +import sys as _sys +from pathlib import Path as _Path + +_LIMIT = 1000 +_SCOPE = ( 'sources', 'tests' ) + + +def find_oversize_modules( + root: _Path, limit: int = _LIMIT, +) -> list[ tuple[ _Path, int ] ]: + ''' Returns (path, line count) for Python modules over the limit. ''' + oversize: list[ tuple[ _Path, int ] ] = [ ] + for scope in _SCOPE: + base = root / scope + if not base.is_dir( ): + continue + for path in sorted( base.rglob( '*.py' ) ): + with path.open( encoding = 'utf-8' ) as f: + count = sum( 1 for _ in f ) + if count > limit: + oversize.append( ( path, count ) ) + return oversize + + +def main( ) -> None: + ''' Fails if any module exceeds the line limit. ''' + oversize = find_oversize_modules( _Path.cwd( ) ) + if not oversize: + print( f'All modules within {_LIMIT} lines.' ) + return + for path, count in oversize: + print( + f'{path}: {count} lines exceeds {_LIMIT}-line limit. ' + f'Split into a subpackage.' ) + _sys.exit( 1 ) + + +if __name__ == '__main__': + main( ) diff --git a/.auxiliary/secrets/.gitignore b/.auxiliary/secrets/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/.auxiliary/secrets/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.github/workflows/claude.yaml b/.github/workflows/claude.yaml new file mode 100644 index 0000000..dadcf5f --- /dev/null +++ b/.github/workflows/claude.yaml @@ -0,0 +1,45 @@ +name: claude + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + workflow_dispatch: + inputs: + prompt: + description: 'Message to send to Claude' + required: true + default: 'Please validate your MCP server setup and available subagents.' + type: string + +jobs: + + initialize: + uses: ./.github/workflows/core--initializer.yaml + + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '/claude') || contains(github.event.issue.title, '/claude'))) || + (github.event_name == 'workflow_dispatch') + needs: [initialize] + permissions: + contents: read + id-token: write + issues: read + pull-requests: read + uses: emcd/python-project-common/.github/workflows/xrepo--claude.yaml@gha-1 + with: + allowed-tools: 'Bash(git add:*),Bash(git branch:*),Bash(git checkout:*),Bash(git diff:*),Bash(git log:*),Bash(git ls-files:*),Bash(git remote:*),Bash(git reset:*),Bash(git rev-parse:*),Bash(git rm:*),Bash(git status),Bash(hatch:*),Bash(pip:*),Bash(python:*),Edit,Write,mcp__context7__resolve-library-id,mcp__context7__get-library-docs,mcp__librovore__query_inventory,mcp__librovore__query_content' + python-version: '${{ fromJSON(needs.initialize.outputs.python-versions)[0] }}' + timeout-minutes: 20 + prompt: '${{ inputs.prompt }}' + secrets: + anthropic-api-key: '${{ secrets.ANTHROPIC_API_KEY }}' diff --git a/.github/workflows/core--initializer.yaml b/.github/workflows/core--initializer.yaml index 673d50a..416103a 100644 --- a/.github/workflows/core--initializer.yaml +++ b/.github/workflows/core--initializer.yaml @@ -55,16 +55,14 @@ jobs: run: | set -eu python_descriptors="$(cat <<'EOF' | yq --output-format=json | jq --compact-output - "3.10": - hatch: "py3.10" - "3.11": - hatch: "py3.11" "3.12": hatch: "py3.12" "3.13": hatch: "py3.13" - "pypy3.10": - hatch: "pypy3.10" + "3.14": + hatch: "py3.14" + "3.15": + hatch: "py3.15" EOF )" echo "specs=${python_descriptors}" >>${GITHUB_OUTPUT} @@ -76,10 +74,8 @@ jobs: id: matrix-exclusions run: | set -eu - # PyPy has slow I/O, even slower on Windows. items="$(jq --compact-output <SHA256SUMS.txt - - name: Sign Distributions - uses: sigstore/gh-action-sigstore-python@v3.0.0 + - name: Attest Build Provenance + uses: actions/attest-build-provenance@v2 with: - inputs: >- + subject-path: | ${{ env.DISTRIBUTIONS_PATH }}/SHA256SUMS.txt ${{ env.DISTRIBUTIONS_PATH }}/classcore-* @@ -127,14 +189,21 @@ jobs: cp .auxiliary/artifacts/tc-release-notes.rst .auxiliary/artifacts/release-notes.rst - name: Create Release + if: ${{ startsWith(github.ref, 'refs/tags/') }} env: GITHUB_TOKEN: ${{ github.token }} run: | + release_flags=( ) + if [[ "${{ github.ref_name }}" =~ ^v?[0-9]+(\.[0-9]+)*(a|b|rc)[0-9]+$ ]]; then + release_flags+=( --prerelease --latest=false ) + fi gh release create '${{ github.ref_name }}' \ --repo '${{ github.repository }}' \ - --notes-file .auxiliary/artifacts/release-notes.rst + --notes-file .auxiliary/artifacts/release-notes.rst \ + "${release_flags[@]}" - name: Publish Artifacts + if: ${{ startsWith(github.ref, 'refs/tags/') }} env: GITHUB_TOKEN: ${{ github.token }} run: | diff --git a/.github/workflows/tester.yaml b/.github/workflows/tester.yaml index a341490..2de0d5c 100644 --- a/.github/workflows/tester.yaml +++ b/.github/workflows/tester.yaml @@ -14,7 +14,7 @@ jobs: test: needs: [initialize] - uses: emcd/python-project-common/.github/workflows/xrepo--tester.yaml@v1.16 + uses: emcd/python-project-common/.github/workflows/xrepo--tester.yaml@gha-1 with: matrix-exclusions: '${{ needs.initialize.outputs.matrix-exclusions }}' platforms: '${{ needs.initialize.outputs.platforms }}' @@ -23,6 +23,6 @@ jobs: report: needs: [initialize, test] - uses: emcd/python-project-common/.github/workflows/xrepo--reporter.yaml@v1.16 + uses: emcd/python-project-common/.github/workflows/xrepo--reporter.yaml@gha-1 with: python-version: '${{ fromJSON(needs.initialize.outputs.python-versions)[0] }}' diff --git a/.gitignore b/.gitignore index 8ae7cd9..0e6bcbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .env +*.so .*.swp __pycache__/ bugs/ +build/ +dist/ diff --git a/README.rst b/README.rst index b80fb0e..72d3e2d 100644 --- a/README.rst +++ b/README.rst @@ -81,14 +81,17 @@ modules. Installation 📦 =============================================================================== -Via `uv `_ ``pip`` +Method: Install Python Package +------------------------------------------------------------------------------- + +Install via `uv `_ ``pip`` command: :: uv pip install classcore -Or, via ``pip``: +Or, install via ``pip``: :: @@ -109,9 +112,9 @@ Note on Immutability 📢 Examples 💡 =============================================================================== -.. Please see the `examples directory -.. `_ for -.. greater detail. +Please see the `examples directory +`_ +for greater detail. Standard Behaviors 🔒 ------------------------------------------------------------------------------- @@ -188,7 +191,24 @@ Use Cases 🎯 * 📦 **Frameworks**: Frameworks with controlled extension and modification. -`More Flair `_ +Contribution 🤝 +=============================================================================== + +Contribution to this project is welcome! However, it must follow the `code of +conduct +`_ +for the project. + +Please file bug reports and feature requests in the `issue tracker +`_ or submit `pull +requests `_ to +improve the source code or documentation. + +For development guidance and standards, please see the `development guide +`_. + + +Additional Indicia =============================================================================== .. image:: https://img.shields.io/github/last-commit/emcd/python-classcore @@ -228,27 +248,30 @@ Other Projects by This Author 🌟 =============================================================================== -* `python-absence `_ - - PyPI: `absence `_ +* `python-absence `_ (`absence `_ on PyPI) + + 🕳️ A Python library package which provides a **sentinel for absent values** - a falsey, immutable singleton that represents the absence of a value in contexts where ``None`` or ``False`` may be valid values. +* `python-accretive `_ (`accretive `_ on PyPI) + + 🌌 A Python library package which provides **accretive data structures** - collections which can grow but never shrink. +* `python-detextive `_ (`detextive `_ on PyPI) + + 🕵️ A Python library which provides consolidated text detection capabilities for reliable content analysis. Offers MIME type detection, character set detection, and line separator processing. +* `python-dynadoc `_ (`dynadoc `_ on PyPI) - 🕳️ A Python library package which provides a **sentinel for absent values** - a falsey, immutable singleton that represents the absence of a value in contexts where ``None`` or ``False`` may be valid values. -* `python-accretive `_ - - PyPI: `accretive `_ + 📝 A Python library package which bridges the gap between **rich annotations** and **automatic documentation generation** with configurable renderers and support for reusable fragments. +* `python-falsifier `_ (`falsifier `_ on PyPI) - 🌌 A Python library package which provides **accretive data structures** - collections which can grow but never shrink. -* `python-falsifier `_ - - PyPI: `falsifier `_ + 🎭 A very simple Python library package which provides a **base class for falsey objects** - objects that evaluate to ``False`` in boolean contexts. +* `python-frigid `_ (`frigid `_ on PyPI) - 🎭 A very simple Python library package which provides a **base class for falsey objects** - objects that evaluate to ``False`` in boolean contexts. -* `python-frigid `_ - - PyPI: `frigid `_ + 🔒 A Python library package which provides **immutable data structures** - collections which cannot be modified after creation. +* `python-icecream-truck `_ (`icecream-truck `_ on PyPI) - 🔒 A Python library package which provides **immutable data structures** - collections which cannot be modified after creation. -* `python-icecream-truck `_ - - PyPI: `icecream-truck `_ + 🍦 **Flavorful Debugging** - A Python library which enhances the powerful and well-known ``icecream`` package with flavored traces, configuration hierarchies, customized outputs, ready-made recipes, and more. +* `python-librovore `_ (`librovore `_ on PyPI) - 🍦 **Flavorful Debugging** - A Python library which enhances the powerful and well-known ``icecream`` package with flavored traces, configuration hierarchies, customized outputs, ready-made recipes, and more. -* `python-mimeogram `_ - - PyPI: `mimeogram `_ + 🐲 **Documentation Search Engine** - An intelligent documentation search and extraction tool that provides both a command-line interface for humans and an MCP (Model Context Protocol) server for AI agents. Search across Sphinx and MkDocs sites with fuzzy matching, extract clean markdown content, and integrate seamlessly with AI development workflows. +* `python-mimeogram `_ (`mimeogram `_ on PyPI) - 📨 A command-line tool for **exchanging collections of files with Large Language Models** - bundle multiple files into a single clipboard-ready document while preserving directory structure and metadata... good for code reviews, project sharing, and LLM interactions. + 📨 A command-line tool for **exchanging collections of files with Large Language Models** - bundle multiple files into a single clipboard-ready document while preserving directory structure and metadata... good for code reviews, project sharing, and LLM interactions. diff --git a/documentation/api.rst b/documentation/api.rst index ef541d3..e06b48f 100644 --- a/documentation/api.rst +++ b/documentation/api.rst @@ -1,5 +1,5 @@ -.. vim: set fileencoding=utf-8: -.. -*- coding: utf-8 -*- +.. vim: set filetype=rst fileencoding=utf-8: +.. -*- mode: rst ; coding: utf-8 -*- .. +--------------------------------------------------------------------------+ | | | Licensed under the Apache License, Version 2.0 (the "License"); | @@ -16,7 +16,8 @@ | | +--------------------------------------------------------------------------+ -:tocdepth: 4 + +:tocdepth: 3 ******************************************************************************* @@ -27,38 +28,13 @@ API Package ``classcore`` =============================================================================== -Foundational class factories and decorators. - -Provides ability to create class decorators and metaclasses with customization -hooks. The metaclasses can apply class decorators inline during the class -construction and initialization process, properly handling cases where -decorators replace classes (e.g., ``dataclasses.dataclass( slots = True )``). -They also backport the repair mechanism from newer versions of CPython to -ensure that the class closure cells are rectified on replaced classes, so that -zero-argument ``super`` calls function correctly in them. - -The ``classcore.standard`` subpackage is an example of the decorators and -customization hooks being used to provide a set of practical classes and class -decorators. Furthermore, the exception classes in the -:py:mod:`classcore.exceptions` module inherit from one of the standard classes, -making both the exception classes, themselves, and their instances immutable -and concealing their non-public attributes to reduce API noise. I.e., this -package "eats its own dog food" and provides practical examples in so doing. - -This package is not as magical as it might seem. It does **not** rely on any -``exec`` or ``eval`` calls and it does **not** do anything with ``ctypes`` or -similar surgical instruments. It relies completely on the documented Python -data model and the machinery that it provides. While it is true that -metaclasses can be tricky, this package is developed with a deep, -highly-evolved understanding of them. We seek simplicity over cleverness and -maintain robust tests across multiple Python implementations and versions. The -package is also very clean in terms of static type checking (via Pyright). +.. automodule:: classcore Module ``classcore.decorators`` ------------------------------------------------------------------------------- -.. automodule:: classcore.factories +.. automodule:: classcore.decorators Module ``classcore.factories`` @@ -88,28 +64,7 @@ Module ``classcore.utilities`` Subpackage ``classcore.standard`` =============================================================================== -Decorators and class factories which imbue concealment and immutability. - -Concealment restricts the visibility of attributes on classes and their -instances. By default, only public attributes (ones which do not start with -``_``) are revealed for :py:func:`dir` calls. This behavior can be overriden by -supplying visibility verifiers as a decorator factory argument or metaclass -argument. These can be a sequence of attribute names, regular expression -:py:class:`re.Pattern` objects which match attribute names, or predicate -functions which match attribute names. Or, total visibility (per the Python -default) can be achieved by supplying ``visibles = '*'`` instead of a sequence -of verifiers. - -Immutability prevents assignment (including reassignment) or deletion of -attrubtes on classes and their instances after they have been completely -initialized. In addition to any standard Python class, this can be applied to -dataclasses, allowing them to use ``__post_init__`` to set attributes, which -``dataclasses.dataclass( frozen = True )`` prevents. The immutability behavior -can be overridden by supplying mutability verifiers as a decorator factory -argument or metaclass argument. These behave similarly to the visibility -verifiers described above. - -Hooks to modify the concealment and immutability behaviors are also available. +.. automodule:: classcore.standard Module ``classcore.standard.classes`` @@ -136,6 +91,12 @@ Module ``classcore.standard.behaviors`` .. automodule:: classcore.standard.behaviors +Module ``classcore.standard.explanations`` +------------------------------------------------------------------------------- + +.. automodule:: classcore.standard.explanations + + Module ``classcore.standard.nomina`` ------------------------------------------------------------------------------- diff --git a/documentation/architecture/openspec/changes/archive/.gitkeep b/documentation/architecture/openspec/changes/archive/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/design.md b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/design.md new file mode 100644 index 0000000..e5b512c --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/design.md @@ -0,0 +1,134 @@ +## Context + +Python resolves the metaclass of a new class by taking the most derived +metaclass among its bases, requiring that metaclass to be a (non-strict) +subclass of every base's metaclass. The check lives in `type.__new__` +and uses C-level MRO subtype tests — `__subclasscheck__` overrides on +candidate metaclasses are ignored. Consequently the only clean way to +make two metaclass families compatible is genuine inheritance. + +Today `Class → type` and `abc.ABCMeta → type` are siblings, so +`class Foo( classcore.Object, SomeABC )` raises `TypeError: metaclass +conflict`. Likewise `ProtocolClass → _ProtocolMeta → ABCMeta` never +passes through `Class`, so protocol bases cannot mix with ordinary +classcore classes. + +## Goals / Non-Goals + +- Goals: + - Allow `class Foo( classcore.AbstractObject, SomeABC )` without + metaclass conflict, preserving standard behaviors. + - Preserve abstract method enforcement and virtual subclass + registration (`register()`, `isinstance()` on registered classes). + - Keep `Class`-backed classes free of ABC machinery (opt-in only). + - Unify the metaclass taxonomy: `ProtocolClass` becomes a descendant + of `Class`, enabling `class Foo( classcore.Protocol, + classcore.Object )`. +- Non-Goals: + - Making `Class` itself an `ABCMeta` descendant — would impose the ABC + contract (registration caches, `__instancecheck__` machinery) on + every classcore class. + - Mutable or dataclass abstract variants (`AbstractObjectMutable`, + `AbstractDataclassObject`) — natural follow-ups once the minimal pair + proves itself. + - Auto-reconciliation of conflicting metaclasses by synthesizing + combined metaclasses at class creation — implicit magic that + manufactures unlisted metaclasses, contrary to the package's + explicit-everything philosophy. + +## Decisions + +### Decision 1: Opt-in diamond metaclass + +```python +@_class_factory( ) +class AbstractClass( Class, __.abc.ABCMeta ): ... + +class AbstractObject( + metaclass = AbstractClass, + class_mutables = _abc_class_mutables, +): ... +``` + +C3 linearization is consistent: +`AbstractClass → Class → ABCMeta → type → object`. Verified empirically: +mixed-base creation with an external ABC succeeds, abstract method +enforcement raises `TypeError` on instantiation of incomplete classes, +and `register()` plus `isinstance()` behave correctly. + +- Alternatives considered: + - `Class( ABCMeta )`. Rejected — changes the contract of `Class`; + every ordinary class would carry ABC machinery. + - Intercepting construction to synthesize a combined metaclass. + Rejected — `type.__new__` performs the conflict check with C-level + subtype tests that bypass `__subclasscheck__`, and manufacturing + unlisted metaclasses is implicit magic. + - Naming `ABCFactory`. Rejected — every metaclass in the family is + named `Class` (`Class`, `ProtocolClass`); `AbstractClass` + follows the convention, as `AbstractObject` follows `*Object`. + +### Decision 2: `class_mutables` on `AbstractObject` + +CPython's C `_abc` module updates the registration caches (`_abc_registry`, +`_abc_cache`, `_abc_negative_cache`) via direct dictionary writes that +bypass metaclass `__setattr__`, so `register()` happens to work without +exemptions on CPython. The pure-Python fallback (`_py_abc`) used by some +interpreters assigns via `setattr`, which classcore immutability would +block. `class_mutables = _abc_class_mutables` (the existing tuple, already +extended for `runtime_checkable`) is cheap, defensive, and consistent +with the four protocol base classes. It also propagates to subclasses +through MRO attribute lookup. + +### Decision 3: `ProtocolClass` re-parenting + +```python +@_class_factory( ) +class ProtocolClass( AbstractClass, type( __.typx.Protocol ) ): ... +``` + +MRO becomes +`ProtocolClass → AbstractClass → Class → _ProtocolMeta(te) → +_ProtocolMeta(typing) → ABCMeta → type → object`. + +This is enabled by the wrapper-level delegation from +`update-protocol-recognition`: parent-metaclass wrappers on the chain +(`AbstractClass`, `Class`) see the closure/context mismatch and delegate +directly to the next `__new__`/`__init__`, so construction hooks and +decorators still execute exactly once (verified with a recording +decorator through the three-level hierarchy). `ProtocolClass` must keep +`@_class_factory( )`; a body-defined `__new__` stub without factory +application shadows the inherited injected `__new__` and silently +bypasses decoration (observed in prototyping). + +Out of scope: directly inheriting a protocol definition base together +with an ordinary base (e.g., `class Foo( Protocol, Object )`). Empirical +finding: `typing_extensions._ProtocolMeta.__new__` rejects ordinary +bases when `typing.Protocol` is a *direct* base ("Protocols can only +inherit from other protocols"), and metaclass unification does not lift +that rule. Intermediating through a classcore protocol base technically +bypasses the direct-base check (the class is creatable), but the result +is a protocol definition carrying concrete implementation ancestors — +contrary to PEP 544's intent and fragile against future typing +validation tightening. Concrete classcore classes remain structurally +compatible with classcore protocols, which is the supported way to mix +the families. + +## Risks / Trade-offs + +- **`ProtocolClass` metaclass MRO deepens** (`AbstractClass`, `Class` + inserted before `_ProtocolMeta`). Behavior is guarded by the existing + delegation exact-once tests plus the full protocol suite. +- **Interpreter variance**: the C `_abc` fast-path bypasses + `__setattr__` on CPython; PyPy or the `_py_abc` fallback may not. + Exemptions (Decision 2) cover both. PyPy has surfaced subtle classcore + bugs before; worth including in any multi-interpreter test matrix. +- **`AbstractObject` is a fresh root** (does not inherit `Object`), + matching the existing convention (`DataclassObject`, `Protocol`). + Mixing `class Foo( AbstractObject, Object )` resolves correctly since + `AbstractClass` is a subclass of `Class`. + +## Open Questions + +None. Naming, hierarchy, and exemption approach settled with the human +(2026-08-14): `AbstractClass` preferred over `ABCFactory`; protocol +re-parenting in scope. diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/proposal.md b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/proposal.md new file mode 100644 index 0000000..3b8c9c6 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/proposal.md @@ -0,0 +1,45 @@ +# Change: Add Abstract Base Class Support + +## Why + +Classes created by classcore metaclasses cannot be combined with external +abstract base classes. `Class` inherits from `type` while `abc.ABCMeta` +also inherits from `type`, so `class Foo( classcore.Object, SomeABC )` +fails with `TypeError: metaclass conflict` — the two metaclasses are +siblings. The same gap separates `ProtocolClass` from `Class`: the +protocol metaclass family never passes through `Class`, so protocol +definition bases cannot share a metaclass lineage with ordinary +classcore classes. + +## What Changes + +- **Add `AbstractClass` metaclass**: combines `Class` with `abc.ABCMeta` + via a diamond hierarchy. Opt-in — `Class` itself remains backed by + plain `type`, so the contract of ordinary classcore classes is + unchanged (no ABC machinery, no registration caches). +- **Add `AbstractObject` base class**: created with `AbstractClass` and + carrying the ABC cache mutable exemptions, for authoring abstract + classes with standard behaviors that mix with external ABCs. +- **Re-parent `ProtocolClass`**: from `type( typx.Protocol )` to + `( AbstractClass, type( typx.Protocol ) )`, unifying the metaclass + taxonomy under `Class`. Protocol behavior is preserved by the + wrapper-level delegation introduced in `update-protocol-recognition`. + Concrete classcore classes remain structurally compatible with + classcore protocols. Direct inheritance of a protocol definition base + alongside an ordinary base (e.g., `Protocol` and `Object` together) + remains unsupported: typing independently rejects ordinary bases in + protocol definitions, and the metaclass unification does not (and + should not) change that. + +## Impact + +- Affected specs: `abc-support` (new capability) +- Affected code: + - `sources/classcore/__/imports.py` — add `abc` to import hub + - `sources/classcore/standard/classes.py` — `AbstractClass`, + `AbstractObject`, `ProtocolClass` re-parenting + - `sources/classcore/__/doctab.py` — documentation fragments as needed + - New tests for hierarchy, mixing, enforcement, registration +- Empirically verified via scratch prototypes (diamond MRO, mixed-base + creation, abstract enforcement, `register()`, exact-once decoration + through the unified hierarchy) diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/specs/abc-support/spec.md b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/specs/abc-support/spec.md new file mode 100644 index 0000000..352b60f --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/specs/abc-support/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: Abstract Class Metaclass + +The `AbstractClass` metaclass MUST combine the standard behaviors of +`Class` with `abc.ABCMeta` via a diamond inheritance hierarchy. `Class` +itself MUST remain backed by plain `type` so that ordinary classcore +classes carry no ABC machinery. + +#### Scenario: Abstract class creation +- **WHEN** a class uses the `AbstractClass` metaclass +- **THEN** the resulting class MUST support standard behaviors + (immutability, concealment) and ABC machinery (abstract method + enforcement, virtual subclass registration) + +#### Scenario: Metaclass hierarchy +- **WHEN** `AbstractClass` is inspected +- **THEN** it MUST be a subclass of both `Class` and `abc.ABCMeta` + +#### Scenario: Ordinary classes unaffected +- **WHEN** a class uses the `Class` metaclass +- **THEN** it MUST NOT gain ABC machinery + +### Requirement: Abstract Base Class + +`AbstractObject` MUST serve as a base class for abstract classes with +standard behaviors, created with `AbstractClass` and exempting ABC cache +attributes from class immutability. + +#### Scenario: Inheritance +- **WHEN** a class inherits from `AbstractObject` +- **THEN** the class MUST be created with `AbstractClass` as its + metaclass, with standard behaviors active + +#### Scenario: Mixing with external abstract base classes +- **WHEN** a class inherits from both `AbstractObject` and an external + class whose metaclass is `abc.ABCMeta` +- **THEN** class creation MUST succeed without + `TypeError: metaclass conflict` + +#### Scenario: Abstract method enforcement +- **WHEN** a class inherits abstract methods from an external ABC and + does not implement them +- **THEN** instantiation MUST raise `TypeError` +- **AND** a subclass implementing all abstract methods MUST instantiate + +#### Scenario: Virtual subclass registration +- **WHEN** `register()` is called on a class derived from + `AbstractObject` +- **THEN** the registered class MUST satisfy `isinstance()` checks +- **AND** ABC cache mutations MUST NOT raise immutability errors + +### Requirement: Unified Protocol Metaclass + +`ProtocolClass` MUST inherit from both `AbstractClass` and the metaclass +of `typing.Protocol`, unifying the metaclass taxonomy under `Class`. + +#### Scenario: Unified taxonomy +- **WHEN** `ProtocolClass` is inspected +- **THEN** it MUST be a subclass of `Class`, `AbstractClass`, and + `abc.ABCMeta` + +#### Scenario: Protocol functionality preserved +- **WHEN** the `protocol-support` capability scenarios are exercised +- **THEN** runtime protocol detection, `@runtime_checkable`, and + `isinstance()` structural subtyping MUST continue to behave as + specified + +#### Scenario: Single decoration through hierarchy +- **WHEN** a class is created through the unified protocol metaclass + hierarchy with a decorator +- **THEN** the decorator MUST be applied exactly once + +#### Scenario: Concrete classes structurally satisfy protocols +- **WHEN** a concrete classcore class (e.g., backed by `Class` or + `AbstractClass`) implements all members of a `@runtime_checkable` + classcore protocol +- **THEN** `isinstance()` MUST return `True` for that protocol diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/tasks.md b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/tasks.md new file mode 100644 index 0000000..0875a7d --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-add-abc-support/tasks.md @@ -0,0 +1,48 @@ +## 1. Implementation + +- [x] 1.1 Add `abc` to the import hub (`sources/classcore/__/imports.py`) +- [x] 1.2 Define `AbstractClass( Class, __.abc.ABCMeta )` in + `sources/classcore/standard/classes.py`: `@_class_factory( )`, + `__new__` typechecker stub, `_dynadoc_fragments_` +- [x] 1.3 Define `AbstractObject` with `metaclass = AbstractClass` and + `class_mutables = _abc_class_mutables`, with `_dynadoc_fragments_` +- [x] 1.4 Re-parent `ProtocolClass` to + `( AbstractClass, type( __.typx.Protocol ) )` +- [x] 1.5 Add documentation fragment entries in + `sources/classcore/__/doctab.py` as needed — all referenced fragments + already resolve; no new entries required +- [x] 1.6 Update `sources/classcore/README.md` taxonomy with + `AbstractClass` / `AbstractObject` (and fix stale `ProtocolObject` + reference) + +## 2. Tests + +- [x] 2.1 Hierarchy: `issubclass( AbstractClass, Class )`, + `issubclass( AbstractClass, abc.ABCMeta )`; unified + `issubclass( ProtocolClass, Class )` and + `issubclass( ProtocolClass, AbstractClass )` +- [x] 2.2 `AbstractObject` creation with standard behaviors (class + immutability enforceable) +- [x] 2.3 Mixing: class inheriting `AbstractObject` and an external + `abc.ABC`-based class is created without `TypeError`; metaclass is + `AbstractClass` +- [x] 2.4 Abstract method enforcement: incomplete subclass raises + `TypeError` on instantiation; complete subclass instantiates +- [x] 2.5 Registration: `register()` on an `AbstractObject` descendant; + registered class satisfies `isinstance()`; no immutability errors +- [x] 2.6 Protocol functionality preserved: existing protocol suite + passes unchanged (detection, `runtime_checkable`, `isinstance`) +- [x] 2.7 Exact-once decoration: recording decorator applied through the + unified `ProtocolClass → AbstractClass → Class` hierarchy invokes once +- [x] 2.8 Structural compatibility: a concrete classcore class implementing + all members of a `@runtime_checkable` classcore protocol satisfies + `isinstance()` for that protocol + +## 3. Validation + +- [x] 3.1 `hatch --env develop run make-all` — all green, 100% coverage +- [x] 3.2 Towncrier fragment for the new capability +- [x] 3.3 `openspec validate add-abc-support --strict` +- [x] 3.4 Type checker canaries: private `_Canary*` declarations at end of + `classes.py` exercise Pyright evaluation of `AbstractObject` subclassing + and protocol definition/implementation; pyright reports 0 diagnostics diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/design.md b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/design.md new file mode 100644 index 0000000..35ae07f --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/design.md @@ -0,0 +1,221 @@ +## Context + +Classcore provides protocol metaclasses that combine `typing.Protocol` with +standard behaviors (immutability, concealment, dynadoc). Three defects in the +current implementation prevent these protocol classes from functioning +correctly at runtime. + +### Root Cause Analysis + +**Metaclass hierarchy**: All three protocol metaclasses independently inherit +from `_ProtocolMeta`: + +``` +ProtocolClass → _ProtocolMeta +ProtocolDataclass → _ProtocolMeta (sibling of ProtocolClass) +ProtocolDataclassMutable → _ProtocolMeta (sibling of both) +``` + +Python requires all metaclasses of a class's bases to be in a subclass +relationship. Since the protocol metaclasses are siblings, multi-base +inheritance involving classes backed by different protocol metaclasses raises +`TypeError: metaclass conflict`. + +**Protocol detection**: `typing_extensions.Protocol.__init_subclass__` contains: + +```python +def __init_subclass__(cls, *args, **kwargs): + super().__init_subclass__(*args, **kwargs) + if not cls.__dict__.get('_is_protocol', False): + cls._is_protocol = any(b is Protocol for b in cls.__bases__) +``` + +The identity check `b is Protocol` (where `Protocol` is +`typing_extensions.Protocol`) fails for classcore subclasses because +classcore's `Protocol` is a different class object. The `__init_subclass__` +explicitly sets `_is_protocol = False` on subclasses, shadowing the inherited +`True` value. This breaks `@runtime_checkable`, `isinstance()` structural +checks, and `__protocol_attrs__` collection. + +Confirmed: classcore's own `Protocol` has `_is_protocol = True` (it directly +inherits from `typing_extensions.Protocol`, satisfying the identity check). +The problem only manifests on subclasses of classcore protocol base classes. + +Confirmed: pyright is unaffected (0 errors) because it performs static +structural analysis without relying on `_is_protocol`. + +**Double-processing risk**: The class factory's constructor +(`factories.py:produce_class_constructor`) has an `in_progress` short-circuit +to prevent recursive decoration. However, it checks the flag *after* +`superf()` returns. When metaclasses form a hierarchy, the parent metaclass's +constructor sets `in_progress = True`, processes, then sets it to `False` +before the child metaclass's constructor checks it. This causes +preprocessors, postprocessors, and decorators to be applied once per +metaclass in the MRO. + +## Goals / Non-Goals + +- Goals: + - Establish a proper metaclass hierarchy for all standard metaclasses + (protocol and non-protocol), for consistency (Principle of Least + Surprise). + - Ensure subclasses of classcore protocol base classes are recognized as + protocols at runtime (`_is_protocol = True`). + - Ensure `@runtime_checkable` works with classcore protocol classes. + - Ensure `isinstance()` structural subtyping works with + `@runtime_checkable` classcore protocols. + - Prevent double-processing when metaclass constructors form a hierarchy. +- Non-Goals: + - Type-checker-side `.pyi` stub changes. Pyright already handles + classcore protocols correctly via static analysis. + +## Decisions + +### Decision 1: Metaclass hierarchy + +Establish proper inheritance hierarchies for both protocol and non-protocol +metaclasses, for consistency. + +**Protocol metaclasses:** + +``` +ProtocolClass → _ProtocolMeta → _ProtocolMeta(typing) → ABCMeta → type +ProtocolDataclass → ProtocolClass +ProtocolDataclassMutable → ProtocolDataclass +``` + +**Non-protocol metaclasses:** + +``` +Class → type +Dataclass → Class +DataclassMutable → Dataclass +``` + +When forming these hierarchies, `@dataclass_transform` metadata on mutable +metaclasses must be verified. Confirmed: `@dataclass_transform` sets +`__dataclass_transform__` in the class's own `__dict__`, which shadows any +inherited value. `ProtocolDataclassMutable` (which omits `frozen_default`) +will correctly have `frozen_default = False` in its own +`__dataclass_transform__`, not inheriting `True` from `ProtocolDataclass`. + +- Alternatives considered: A metaclass `__init_subclass__` shim to resolve + conflicts dynamically. Rejected — proper hierarchy is simpler and more + correct. + +### Decision 2: Protocol detection fix + +Detect protocol base classes structurally, in the `ProtocolClass.__new__` +stub. A base is a protocol base class when it has `_is_protocol = True` +in its own `__dict__` (not merely inherited) and has a typing `Protocol` +class as a direct base (`_protocol_cls_set & set(base.__bases__)`). +When found, set `_is_protocol = True` on the new class before any +decorators are applied, so `decorators = (typing.runtime_checkable,)` +sees the correct value. + +- Semantics: `class MyProto(classcore.Protocol)` gets `_is_protocol = True`. + `class Impl(MyProto)` gets `_is_protocol = False` (concrete implementation, + no protocol base class in `__bases__`). +- This matches standard `typing` semantics where a class is a protocol only + if it directly inherits from `Protocol`. +- Third-party extensibility: adding a typing `Protocol` to the bases of a + classcore protocol subclass makes it a protocol base class in turn, + mirroring standard typing usage. +- Alternatives considered: + - Sentinel attribute (`_classcore_protocol_base`) on each protocol base + class's `__dict__`. Rejected — visible via `__getattribute__` on all + subclasses (namespace pollution), and redundant with the structural + signal already present. + - Module-level registry (`frozenset`). Rejected — a frozen registry + precludes extension by third-party packages; a mutable registry invites + mutation by callers who should instead use the framework properly. + - Checking metaclass type (`isinstance(b, ProtocolClass)`). Rejected — + cannot distinguish protocol definitions from concrete implementations, + since both use the same metaclass. + +### Decision 3: Where to implement the detection fix + +Split across the two metaclass hook sites, each for a distinct reason: + +- `ProtocolClass.__new__`: corrects `_is_protocol` immediately after class + creation. Must run before decorator application (inside the constructor's + `superf()` chain) so `typing.runtime_checkable` via `decorators=` sees + `_is_protocol = True`. +- `ProtocolClass.__init__`: replaces `__protocol_attrs__` after + `_ProtocolMeta.__init__` computes it (see Decision 5). + +`__init_subclass__` was rejected as the site: it would need to be defined +on all four protocol base classes (they do not inherit from each other at +the class level), and it runs before decorators are known. + +### Decision 4: Double-processing prevention + +Implement hierarchy delegation in the decorator wrappers +(`decorators.py`), not in the produced constructors/initializers. Each +wrapper already holds the defining metaclass in its closure (`clscls`) +and receives the most-derived metaclass as its call context (`clscls_` +for construction, `type(cls)` for initialization). On mismatch, the +wrapper delegates directly past its own constructor/initializer — the +parent metaclass's machinery never runs a second time. + +`ClassConstructor` and `ClassInitializer` keep their original public +signatures; no major release is required for this change. + +- Alternatives considered: + - `origin`/`defining_clscls` parameter threaded from wrapper to + constructor (`if origin is not clscls: return`). Implemented and + reviewed first, then withdrawn — it changed the public callback + signatures for an internal concern, forcing a major version. + - Only applying `@_class_factory()` to `ProtocolClass` and having child + metaclasses inherit factory behaviors. Rejected — the `__new__` stub + in each metaclass's class body would shadow the inherited + factory-injected `__new__`, bypassing preprocessing; also the wrappers' + closure variables would reference the parent metaclass, defeating + start-of-MRO behavior enforcement. + +### Decision 5: Snapshot-based `__protocol_attrs__` + +`typing_extensions` computes `__protocol_attrs__` by scanning the +decorated namespace (all of `__dict__` and `__annotations__` across the +MRO), which collects framework-injected attributes (behavior dunders, +dataclass-generated methods, classcore internals). Reordering decoration +cannot help: `_ProtocolMeta.__init__` always runs after `__new__`, +after all decoration, and `runtime_checkable` itself sets attributes on +the finished class. + +Instead, snapshot the declared members in `ProtocolClass.__new__` +immediately after class creation — the pre-decoration namespace contains +exactly what the author declared. `ProtocolClass.__init__` then replaces +the computed attrs with `declared | union(inherited)` over +`ProtocolClass`-family bases. + +- A user-declared dunder (e.g., `__repr__`) is preserved as a protocol + member; framework-generated dunders never enter the snapshot, so no + dataclass-dunder blacklist is needed. +- Exclusions are limited to class-creation machinery (`__abstractmethods__`, + `__parameters__`, `__init__`, `__orig_bases__`), typing internals, and + `_classcore_`/`_abc_` prefixes. +- The snapshot is idempotent and survives dataclass class reproduction + (`_add_slots` copies `__dict__`, including the snapshot attribute). +- Alternatives considered: filtering the post-decoration attrs with a + blacklist of framework dunder names. Implemented and reviewed first, then + withdrawn — unmaintainable and silently drops user-declared dunders. + +## Risks / Trade-offs + +- **Metaclass hierarchy change is backward-compatible**: Existing code using a + single protocol metaclass is unaffected. The change only enables + previously-impossible multi-metaclass inheritance. +- **`_is_protocol` fix changes runtime behavior**: Subclasses of classcore + protocol base classes will now have `_is_protocol = True` instead of + `False`. This is a bug fix — the previous behavior was incorrect. But any + code relying on the buggy behavior (unlikely) would break. +- **`__protocol_attrs__` snapshot is broader than the standard scan**: A + user-declared dunder now counts toward structural checks, so a candidate + lacking it fails `isinstance`. This matches standard `typing`, which also + includes declared dunders, so behavior diverges only from the interim + blacklist, not from standard typing semantics. + +## Open Questions + +None remaining. All resolved during review. diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/proposal.md b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/proposal.md new file mode 100644 index 0000000..bac9d0a --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/proposal.md @@ -0,0 +1,73 @@ +# Change: Fix Protocol Metaclass Hierarchy and Runtime Recognition + +## Why + +Classcore's protocol classes do not properly integrate with `typing.Protocol`'s +runtime machinery. Three interrelated defects prevent subclasses of classcore +protocol base classes from being recognized as protocols at runtime: + +1. **Metaclass hierarchy is flat** — `ProtocolClass`, `ProtocolDataclass`, and + `ProtocolDataclassMutable` all independently inherit from + `_ProtocolMeta`. They do not form a subclass relationship, causing + `TypeError: metaclass conflict` when a user attempts to inherit from + classes backed by different protocol metaclasses (e.g., `Protocol` and + `DataclassProtocol`). + +2. **`_is_protocol` is not set on subclasses** — + `typing_extensions.Protocol.__init_subclass__` uses identity comparison + (`b is Protocol`) to determine whether a subclass is a protocol. Because + classcore's `Protocol` is not `typing_extensions.Protocol`, subclasses of + classcore protocol base classes get `_is_protocol = False` explicitly set + on them, shadowing the inherited `True`. This breaks: + - `@runtime_checkable` (raises `TypeError` at runtime) + - `isinstance()` structural subtyping checks + - `__protocol_attrs__` collection + +3. **Protocol base class not statically recognized as sufficient** — + Related to (2); pyright currently handles this correctly via static + structural analysis, but runtime behavior is broken. + +## What Changes + +- **Establish metaclass hierarchies**: Both protocol and non-protocol + metaclasses SHALL form proper inheritance hierarchies for consistency. + Protocol: `ProtocolDataclass(ProtocolClass)`, + `ProtocolDataclassMutable(ProtocolDataclass)`. + Non-protocol: `Dataclass(Class)`, `DataclassMutable(Dataclass)`. + This eliminates metaclass conflicts for cross-metaclass inheritance. + `@dataclass_transform` metadata on mutable metaclasses is correctly + shadowed (verified: each metaclass gets its own `__dataclass_transform__` + in its `__dict__`). + +- **Fix protocol detection for subclasses**: Override protocol detection logic + so that direct subclasses of classcore protocol base classes + (`Protocol`, `ProtocolMutable`, `DataclassProtocol`, + `DataclassProtocolMutable`) are properly recognized as protocols at runtime + (`_is_protocol = True`). Concrete implementation subclasses (subclasses of + a protocol that do not themselves inherit from a protocol base class) + SHALL remain non-protocols, matching standard `typing` semantics. + +- **Guard against double-processing in constructors**: When protocol + metaclasses form a hierarchy, the class factory's constructor wrappers + could execute preprocessors/postprocessors multiple times (once per + metaclass in the MRO). The decorator wrappers SHALL delegate directly + past their constructor/initializer when the defining metaclass differs + from the call context, so processing runs exactly once. Public + `ClassConstructor`/`ClassInitializer` signatures are unchanged. + +- **Snapshot declared protocol members**: `__protocol_attrs__` SHALL be + computed from the pre-decoration namespace (what the author declared), + not from a post-decoration scan that includes framework-injected and + dataclass-generated attributes. User-declared dunders SHALL be + preserved as protocol members. + +## Impact + +- Affected specs: `protocol-support` +- Affected code: + - `sources/classcore/standard/classes.py` — metaclass hierarchy (both + protocol and non-protocol), protocol detection, declared-member + snapshot, `__protocol_attrs__` override + - `sources/classcore/decorators.py` — wrapper-level hierarchy delegation + - New tests for runtime protocol recognition, metaclass hierarchy, and + cross-metaclass inheritance diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/specs/protocol-support/spec.md b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/specs/protocol-support/spec.md new file mode 100644 index 0000000..9163cce --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/specs/protocol-support/spec.md @@ -0,0 +1,65 @@ +## MODIFIED Requirements + +### Requirement: Protocol Metaclass + +The protocol metaclasses MUST combine `typing.Protocol` with standard +behaviors and MUST form a proper inheritance hierarchy. + +#### Scenario: Protocol class creation +- **WHEN** a class uses a protocol metaclass +- **THEN** the resulting class MUST support structural subtyping with + immutability and concealment + +#### Scenario: Metaclass hierarchy +- **WHEN** `ProtocolDataclass` and `ProtocolDataclassMutable` are inspected +- **THEN** `ProtocolDataclass` MUST be a subclass of `ProtocolClass` +- **AND** `ProtocolDataclassMutable` MUST be a subclass of `ProtocolDataclass` + +#### Scenario: Cross-metaclass inheritance +- **WHEN** a class inherits from bases backed by different protocol + metaclasses (e.g., `Protocol` and `DataclassProtocol`) +- **THEN** class creation MUST succeed without `TypeError: metaclass conflict` + +#### Scenario: Dataclass transform preservation +- **WHEN** `ProtocolDataclassMutable` inherits from `ProtocolDataclass` +- **THEN** `ProtocolDataclassMutable.__dataclass_transform__` MUST have + `frozen_default = False` +- **AND** the value MUST be in `ProtocolDataclassMutable.__dict__`, not + inherited from `ProtocolDataclass` + +## ADDED Requirements + +### Requirement: Runtime Protocol Recognition + +Direct subclasses of classcore protocol base classes MUST be recognized as +protocols at runtime. The `_is_protocol` attribute MUST be set to `True` on +classes that directly inherit from a classcore protocol base class +(`Protocol`, `ProtocolMutable`, `DataclassProtocol`, +`DataclassProtocolMutable`). Concrete implementation subclasses (subclasses +of a protocol that do not include a protocol base class in their own +`__bases__`) MUST have `_is_protocol` set to `False`, matching standard +`typing.Protocol` semantics. + +#### Scenario: Direct subclass recognized as protocol +- **WHEN** a class directly inherits from a classcore protocol base class +- **THEN** `cls._is_protocol` MUST be `True` + +#### Scenario: Concrete implementation not recognized as protocol +- **WHEN** a class inherits from a protocol subclass without including a + protocol base class in its own `__bases__` +- **THEN** `cls._is_protocol` MUST be `False` + +#### Scenario: Runtime checkable application +- **WHEN** `typing.runtime_checkable` is applied to a classcore protocol + subclass via the `decorators=` metaclass argument +- **THEN** the application MUST succeed without `TypeError` +- **AND** `cls._is_runtime_protocol` MUST be `True` + +#### Scenario: Runtime structural subtyping +- **WHEN** a classcore protocol is decorated with `@runtime_checkable` +- **AND** a candidate object implements all protocol methods +- **THEN** `isinstance(candidate, protocol)` MUST return `True` + +#### Scenario: Protocol attributes collection +- **WHEN** a classcore protocol subclass is created +- **THEN** `cls.__protocol_attrs__` MUST contain the protocol's attribute names diff --git a/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/tasks.md b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/tasks.md new file mode 100644 index 0000000..1198cbc --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-15-update-protocol-recognition/tasks.md @@ -0,0 +1,94 @@ +## 1. Metaclass Hierarchy + +### 1a. Protocol metaclasses + +- [x] 1.1 Change `ProtocolDataclass` base from `type(typx.Protocol)` to + `ProtocolClass` in `sources/classcore/standard/classes.py` +- [x] 1.2 Change `ProtocolDataclassMutable` base from `type(typx.Protocol)` + to `ProtocolDataclass` in `sources/classcore/standard/classes.py` +- [x] 1.3 Verify `_dynadoc_fragments_` on each metaclass are preserved + (each overrides in its own `__dict__`) +- [x] 1.4 Verify `dataclass_transform` markers are preserved and correctly + shadowed: `ProtocolDataclassMutable` must have `frozen_default = False` + in its own `__dataclass_transform__` + +### 1b. Non-protocol metaclasses + +- [x] 1.5 Change `Dataclass` base from `type` to `Class` in + `sources/classcore/standard/classes.py` +- [x] 1.6 Change `DataclassMutable` base from `type` to `Dataclass` in + `sources/classcore/standard/classes.py` +- [x] 1.7 Verify `_dynadoc_fragments_` and `dataclass_transform` markers + are preserved on non-protocol metaclasses + +## 2. Constructor Double-Processing Guard + +- [x] 2.1 Implement hierarchy delegation in the decorator wrappers + (`produce_class_construction_decorator` and + `produce_class_initialization_decorator` in + `sources/classcore/decorators.py`): on mismatch between the closure + metaclass and the call context (`clscls is not clscls_` for + construction, `clscls is not type(cls)` for initialization), delegate + directly past the constructor/initializer. +- [x] 2.2 Keep `ClassConstructor` and `ClassInitializer` public signatures + unchanged in `sources/classcore/nomina.py` (an `origin` parameter was + implemented first, then withdrawn to avoid a breaking API change). +- [x] 2.3 Test that a class decorator records exactly one invocation for a + class created through a custom metaclass hierarchy + (`test_444_factory_hierarchy_delegation`) +- [x] 2.4 Run existing test suite to verify no regressions from the guard + change + +## 3. Protocol Detection Fix + +- [x] 3.1 Add `_protocol_cls_set` constant (`frozenset({typx.Protocol})`) + to `sources/classcore/standard/classes.py` +- [x] 3.2 Add structural detection in `ProtocolClass.__new__` stub: check + if any base has `_is_protocol = True` in its `__dict__` AND has a + typing Protocol class as a direct base. Set `_is_protocol = True` + before decorators are applied. +- [x] 3.3 Snapshot declared members in `ProtocolClass.__new__` before + decoration, and replace `__protocol_attrs__` with + `declared | inherited` in `ProtocolClass.__init__` (after + `_ProtocolMeta.__init__` computes it). +- [x] 3.4 Add `_is_runtime_protocol` and `__non_callable_proto_members__` + to `_abc_class_mutables` so `runtime_checkable` can set these attributes + without triggering immutability errors. +- [x] 3.5 Verify that `_is_protocol` is `True` on direct subclasses and + `False` on concrete implementation subclasses + +## 4. Tests + +- [x] 4.1 Test protocol metaclass hierarchy: `issubclass(ProtocolDataclass, ProtocolClass)` + and `issubclass(ProtocolDataclassMutable, ProtocolDataclass)` +- [x] 4.2 Test non-protocol metaclass hierarchy: `issubclass(Dataclass, Class)` + and `issubclass(DataclassMutable, Dataclass)` +- [x] 4.3 Test cross-metaclass inheritance: module-level class inheriting + from `Protocol` and `DataclassProtocol`, created without `TypeError`, + with `ProtocolDataclass` selected as metaclass +- [x] 4.4 Test `_is_protocol` is `True` on direct subclasses of protocol + base classes +- [x] 4.5 Test `_is_protocol` is `False` on concrete implementation + subclasses +- [x] 4.6 Test `@runtime_checkable` succeeds when applied via `decorators=` + mechanism +- [x] 4.7 Test `@runtime_checkable` succeeds when applied directly +- [x] 4.8 Test `isinstance()` structural subtyping works with + `@runtime_checkable` classcore protocol +- [x] 4.9 Test `runtime_checkable` and `isinstance` for direct subclasses + of all four protocol base classes +- [x] 4.10 Test `dataclass_transform` preservation (frozen and mutable) +- [x] 4.11 Test user-declared dunders are preserved in + `__protocol_attrs__` +- [x] 4.12 Test dataclass protocol attrs contain declared members only + (module-level class) +- [x] 4.13 Test factory hierarchy delegation applies a decorator exactly + once (custom metaclass hierarchy) +- [x] 4.14 Run full test suite — all tests pass + +## 5. Validation + +- [x] 5.1 Run `hatch --env develop run make-all` — all green, 100% coverage +- [x] 5.2 Add towncrier fragments for user-facing behavior changes +- [x] 5.3 Update `issues/classes/1`, `issues/classes/2`, `issues/classes/3` + in `nb` with resolution status diff --git a/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/.openspec.yaml b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/.openspec.yaml new file mode 100644 index 0000000..f161d5c --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-16 diff --git a/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/design.md b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/design.md new file mode 100644 index 0000000..7db08f4 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/design.md @@ -0,0 +1,82 @@ +## Context + +Attribute behavior decisions are computed in +`standard/behaviors.py` by three cores — assign, delete, survey — each +walking the same cascade: read the active-behaviors set for the level +(labels `immutability` and `concealment`), then consult per-level +exclusion configuration (names with `'*'` wildcard, predicates, +regexes) for the operation (mutables for assign/delete, visibles for +survey). Cores receive the raw level (`instances`/`classes`) and use it +for exclusion configuration lookup, normalizing only the active-behaviors +lookup. The survey core additionally has a known quirk: its per-name +matching loops `continue` the inner loop only, so a name matching +multiple rules is yielded multiple times; CPython `dir()` does not +deduplicate custom `__dir__` output, so duplicates reach users. + +## Goals / Non-Goals + +**Goals:** +- Extract the decision cascade into pure helpers shared by the cores and + the new explanations API, preserving behavior exactly. +- Provide `explain_attribute( target, name )` returning immutable records + with genuine nested immutability. +- Specify stable, deterministic rendering for rule details. +- Mark framework-internal and stdlib-machinery attributes. + +**Non-Goals:** +- Whole-object explanation sweeps (`survey_explanations`). +- Repairing the survey duplicate-yield quirk (separate change). +- Caching/performance work (`/8` follow-up; claims deferred until + extraction boundaries exist and are profiled). +- Error-message integration (deferred, separately decided). +- `eval`-able rule detail strings. + +## Decisions + +- Module: `standard/explanations.py` (Latinate, matches the + `explain_attribute` verb; avoids stdlib `inspect` confusion). +- Naming: decision hierarchy PermitBy* / Prohibit (Latinate verbs); + `PermitByOmni` follows the package's `*Omni` wildcard vocabulary + (BehaviorExclusionNamesOmni). +- Verdict shapes follow a single closed decision hierarchy for all + three operations: PermitByInapplicability (behavior inactive), + PermitByOmni, PermitByNames, PermitByPredicate, PermitByRegex, and + Prohibit (behavior active, nothing permitted). All operations apply + precedence semantics (omni, names, first predicate, first regex). + Permissibility is a derived property of the verdict + (not isinstance(decision, Prohibit)) — no stored boolean, no + __bool__. Payloads are typed per decision (name, qualified predicate + text, pattern text) so a name colliding with a genus label or pattern + cannot be confused with one. +- Survey first-match is an owner decision reversing the earlier + union-trace design: the union trace existed to faithfully describe + the duplicate-yield behavior, which is itself characterized as a + defect with normalization deferred. Specifying the explanation to + the normalized (first-match) semantics now makes explanation and + repaired behavior coincide; until the repair, the survey core keeps + determine_relevant_permitters internally to reproduce current behavior. +- Level semantics: class targets evaluate the `classes`-level + configuration; instance targets evaluate the `instances`-level + configuration against the instance's class hierarchy. Exclusion + configuration uses the raw level; the normalized behavior level is + recorded separately on the explanation. +- Decision payload policy: names decisions carry the matched name; + predicate decisions carry qualified-name text with an `''` + fallback; regex decisions carry pattern text; omni and prohibition + decisions carry no payload. Payloads are typed per decision class, + so a name colliding with a pattern or label cannot be confused. +- `AttributeExplanation.internal` marks classcore-owned named/mangled + attributes and stdlib ABC machinery (class mutables exemption sets). +- Survey duplicate-yield is preserved by the extraction and + characterized by a test asserting current behavior. + +## Risks / Trade-offs + +- Extraction refactor could subtly change core behavior; mitigated by + characterizing tests (including the duplicate-yield case) written + before and kept after the refactor. +- String-only rule details limit programmatic consumers; accepted for + serializability and implementation hiding. +- The explanations API expands public surface that a future native + backend must keep answering; accepted as the semantic pinning that + makes that migration safe. diff --git a/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/proposal.md b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/proposal.md new file mode 100644 index 0000000..290c7f6 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/proposal.md @@ -0,0 +1,71 @@ +## Why + +Classcore's attribute behaviors are opaque. When an attribute surprises a +user — immutable when expected mutable, absent from `dir()` when expected +visible — the reason lives in a decision cascade spread across +`behaviors.py`: the active-behaviors sets (immutability and concealment +labels), per-level exclusion configuration (names, the `'*'` wildcard, +predicates, regexes), and stdlib machinery exemptions. There is no way to +ask classcore *why*; users must reverse-engineer from source, and even +maintainers re-derive the cascade from scratch each time (the recent +mangling work confirmed this). + +Formalizing the decision model as a first-class, inspectable artifact has +standalone value, and it is a prerequisite for the deferred Rust +native-storage work: semantic pinning before any native commitment. + +## What Changes + +Add a public attribute explanations capability to `classcore.standard`, +in a new `explanations.py` module: + +- `explain_attribute( target, name )` — returns the complete decision + trace for attribute `name` on `target` (class or instance): active + behaviors per evaluated level, and per-operation verdicts, each + carrying exactly one decision. +- A closed decision hierarchy — `PermitByInapplicability`, + `PermitByOmni`, `PermitByNames`, `PermitByPredicate`, + `PermitByRegex`, and `Prohibit` — with typed payloads; `Verdict` and + its per-operation subclasses (`AssignVerdict`, `DeleteVerdict`, + `SurveyVerdict`) with derived permissibility; and + `AttributeExplanation` — all immutable (frozen, concealed) records, + with nested collections genuinely immutable. All operations, survey + included, follow precedence semantics (omni, names, first matching + predicate, first matching regex). +- The decision cascade in `behaviors.py` is extracted into pure helpers + consumed by both the behavior cores and the explanations, so the + helpers are shared wherever semantics coincide. Survey is the + deliberate exception: the explanation reports normalized first-match + semantics while the core preserves the once-per-matching-rule + duplication, which is characterized as a defect with its repair + deferred separately. + +Scope boundaries for the first release: `explain_attribute` only (no +whole-object surveys); rule details render as plain strings with a +specified policy (no `eval` round-trip promised); explanations are +observational and do not bypass or alter concealment or immutability; +framework-internal and stdlib-machinery attributes are explainable and +marked as internal. + +## Capabilities + +### New Capabilities +- `attribute-explanations`: public API for explaining attribute behavior + decisions — verdicts, decision rules, level semantics, record + immutability, and internal-attribute marking. + +### Modified Capabilities + + +## Impact + +- New module `sources/classcore/standard/explanations.py`; records are + defined there directly, without nomina aliases. +- `sources/classcore/standard/behaviors.py`: decision-cascade extraction + (behavior-preserving refactor). +- README (taxonomy/API sections); towncrier fragment + `+attribute-explanations.enhance.rst`. +- No public API changes to existing capabilities. diff --git a/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/specs/attribute-explanations/spec.md b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/specs/attribute-explanations/spec.md new file mode 100644 index 0000000..eba5fd7 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/specs/attribute-explanations/spec.md @@ -0,0 +1,131 @@ +## ADDED Requirements + +### Requirement: Attribute Explanation + +The `explain_attribute` function MUST return the decision trace for the +named attribute on the target: the active behaviors for each evaluated +level, and for each operation the applicable verdict. Every verdict MUST +carry exactly one decision, drawn from a closed hierarchy: permitted by +inapplicability (the governing behavior is inactive), permitted by the +omni marker, permitted by names membership, permitted by a predicate, +permitted by a regex, or prohibited (the behavior is active and no rule +permits). All three operations — assign, delete, and survey — MUST +apply the same precedence semantics: omni, then names membership, then +the first matching predicate, then the first matching regex. + +For class targets, the classes-level configuration MUST be evaluated. +For instance targets, the instances-level configuration MUST be +evaluated against the instance's class hierarchy. Survey verdicts MUST +explain the visibility of the supplied name only. Explanations MUST be +observational: they MUST NOT bypass or alter concealment or +immutability. + +Note: survey explanations describe the normalized first-match semantics. +The survey core currently yields a name once per matching rule; that +duplication is characterized as a defect and deferred to a separate +repair, after which core behavior and explanation semantics coincide. + +#### Scenario: Mutable attribute +- **WHEN** the active behaviors for the evaluated level lack + immutability +- **THEN** the assign and delete verdicts MUST be permitted by + inapplicability + +#### Scenario: Excluded by name +- **WHEN** the attribute name is in the exclusion names for the + operation's level +- **THEN** the verdict MUST be permitted by names, carrying the matched + name + +#### Scenario: Excluded by regex +- **WHEN** an exclusion regex fullmatches the attribute name +- **THEN** the verdict MUST be permitted by regex, carrying the pattern + text + +#### Scenario: Excluded by predicate +- **WHEN** an exclusion predicate matches the attribute name +- **THEN** the verdict MUST be permitted by predicate, carrying the + predicate's fully-qualified name, or `` when unavailable + +#### Scenario: Omni exclusion +- **WHEN** the exclusion names for assign or delete equal `'*'` +- **THEN** the verdict MUST be permitted by omni + +#### Scenario: Immutable attribute +- **WHEN** immutability is active and no rule permits the operation +- **THEN** the assign and delete verdicts MUST be prohibited + +#### Scenario: Concealed attribute +- **WHEN** concealment is active and no visibles rule matches the name +- **THEN** the survey verdict MUST be prohibited + +#### Scenario: Survey multi-rule precedence +- **WHEN** the attribute name matches a predicate and a regex and is not + in the exclusion names +- **THEN** the survey verdict MUST be permitted by the predicate, the + first rule under precedence, and MUST NOT carry the regex + +#### Scenario: Survey names precedence +- **WHEN** the attribute name is in the exclusion names for the survey + level and also matches a predicate or regex +- **THEN** the survey verdict MUST be permitted by names and predicates + and regexes MUST NOT be evaluated for that name + +#### Scenario: Survey omni +- **WHEN** concealment is active and the visibles names equal `'*'` +- **THEN** the survey verdict MUST be permitted by omni and predicates + and regexes MUST NOT be evaluated + +#### Scenario: Survey concealment inactive +- **WHEN** the active behaviors for the evaluated level lack concealment +- **THEN** the survey verdict MUST be permitted by inapplicability + +#### Scenario: Permissible derivation +- **WHEN** any verdict's decision is not prohibited +- **THEN** the verdict MUST report itself as permissible + +### Requirement: Decision Payload Distinction + +Decision payloads MUST be distinguished by type, so that a name which +coincides with a genus label or pattern text cannot be confused with +it. The names decision MUST carry the matched name; the predicate +decision MUST carry qualified-name text; the regex decision MUST carry +pattern text. Payloads MUST be plain text. + +#### Scenario: Names payload collision +- **WHEN** the explained attribute is literally named `regex` and is + permitted by names membership +- **THEN** the decision MUST be a names decision carrying the name + `regex`, distinct from any regex decision carrying pattern text + +### Requirement: Record Immutability + +The explanation records MUST be immutable: the decision hierarchy, the +verdicts, and `AttributeExplanation` MUST NOT permit field assignment, +and every nested collection they contain MUST be an immutable +structure. Verdict permissibility MUST be derived from the decision, +not stored. + +#### Scenario: Frozen records +- **WHEN** a field is assigned on any explanation record +- **THEN** an immutability error MUST be raised + +#### Scenario: Immutable nested collections +- **WHEN** the operations mapping, the behaviors mapping, or a payload + sequence is mutated +- **THEN** an error MUST be raised + +### Requirement: Internal Attribute Marking + +Explanations MUST mark attributes that are framework-owned — classcore +named or mangled attributes — or stdlib machinery — class mutables +exemption sets — as internal. + +#### Scenario: Framework-internal attribute +- **WHEN** the explained name is framework-owned or stdlib machinery +- **THEN** the explanation MUST be marked internal + +#### Scenario: User attribute +- **WHEN** the explained name is neither framework-owned nor stdlib + machinery +- **THEN** the explanation MUST NOT be marked internal diff --git a/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/tasks.md b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/tasks.md new file mode 100644 index 0000000..7462c22 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-08-22-add-attribute-explanations/tasks.md @@ -0,0 +1,42 @@ +## 1. Decision Cascade Extraction + +- [x] 1.1 Write characterizing tests for the current core behavior in + `behaviors.py`, including the survey duplicate-yield quirk (a name + matching a predicate and a regex is yielded twice) +- [x] 1.2 Extract the assign/delete decision cascade and the survey + matching logic into pure helpers with explicitly designed boundaries, + consumed by the cores; behavior MUST be preserved exactly +- [x] 1.3 Validate: full suite on develop and qa.pypy3.11 unchanged + +## 2. Explanations Module + +- [x] 2.1 Define the explanation records directly in + standard/explanations.py (AttributeExplanation, Verdict subclasses + AssignVerdict/DeleteVerdict/SurveyVerdict, and the Decision hierarchy + PermitByInapplicability/PermitByOmni/PermitByNames/PermitByPredicate/ + PermitByRegex/Prohibit) with documentation annotations; nomina + aliases omitted as the records are classes, not aliases, and are + colocated with explain_attribute +- [x] 2.2 Implement `standard/explanations.py`: the records as frozen, + concealed classes with genuinely immutable nested collections, and + `explain_attribute( target, name )` built on the extracted helpers +- [x] 2.3 Implement internal-attribute marking: classcore-owned + named/mangled attributes and stdlib class-mutables exemption sets + +## 3. Specification Conformance + +- [x] 3.1 Tests for every scenario in the attribute-explanations delta + (re-scoped to the decision-hierarchy verdict shape): + verdict semantics per operation under precedence — survey included, + first-match semantics, distinct from the core's deferred + duplicate-yield defect — level evaluation for class versus instance + targets, observational (non-bypass) behavior, record and nested + immutability, internal marking +- [x] 3.2 Validate detail rendering policy: name/`'*'`/pattern text/ + qualified predicate name with `` fallback + +## 4. Documentation + +- [x] 4.1 README: taxonomy and API sections for the explanations module +- [x] 4.2 Towncrier fragment `+attribute-explanations.enhance.rst` +- [x] 4.3 Full validation: make-all plus qa.pypy3.11 diff --git a/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/.openspec.yaml b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/.openspec.yaml new file mode 100644 index 0000000..6529e83 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/design.md b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/design.md new file mode 100644 index 0000000..1725604 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/design.md @@ -0,0 +1,129 @@ +## Context + +Enforcement already scopes machinery exemptions per metaclass: +`class_mutables = abc_class_mutables` is declared only on ABC-carrying +metaclasses, so a plain class naming an attribute `_abc_cache` receives +no exemption. Marking, however, matches names globally in +`is_internal_name`, which would mislabel that same attribute. Both +downstream inventories (Accretive, Frigid, 2026-08-22) report static, +uniformly prefixed internal names produced by `calculate_attrname` +namers — identical in structure to classcore's own — with no +MRO-dependent names. `_dynadoc_fragments_` is confirmed by the Dynadoc +owner as a documented user-settable declaration convention. + +## Goals / Non-Goals + +**Goals:** +- Truthful internal marking for classcore, stdlib machinery, and + downstream frameworks' names, with responsibility carried by the + machinery that builds each class. +- Marking scope mirroring enforcement scope (per-metaclass, inherited). +- Detector registration through the existing `attributes_namer` seam — + no new factory/decorator argument slots. +- Determiners pure `(name) -> bool`-style callables (native-storage + constraint). + +**Non-Goals:** +- Attribution on explanation records (which framework's contribution + marked the name) — deliberate 2.0 revisit. +- A global fixed-name registry or fallback tier (no consumer exists; + additive later if one appears). +- Wiring contributions into `class_mutables` enforcement decisions. + (Contributions become the shared store that enforcement *could* later + read, but enforcement semantics are unchanged here.) +- MRO-dependent registrant signatures beyond what the ABC set needs + internally. + +## Decisions + +- Metaclass-carried contributions attach at factory/decoration time: + the factory closure records its namer's detector; the abstract-base + metaclass adds the stdlib machinery set after factory decoration + through a private wiring function — no public factory or decorator + argument is added anywhere. Subclasses in any package inherit + contributions via the metaclass, which resolves multi-package + inheritance by construction. +- Decorator-path exception: plain decorated classes (no custom + metaclass exists; `type` builds them) carry their contribution on + the class itself, and consultation walks the class resolution order + after the metaclass chain. Subclassing a decorated class keeps the + marking via the class MRO. +- The namer detector attribute is canonically `is_internal_name`, + mirroring the module-level function it specializes. Namer detectors + over prefix arguments: a detector callable validates + shape, not just prefix — classcore's mangled names carry 64-hex + digest suffixes, and a detector can require them, so a lookalike + name without a valid digest does not mark. Classcore's + `calculate_attrname` is annotated as the model implementation. +- `is_internal_name(target, name)`: marking consults the metaclass + MRO. For a class target `C` this is `type(C).__mro__`; for an + instance target `x` it is `type(type(x)).__mro__` — the metaclass + MRO of the instance's type. Note `type(x).__mro__` is the *class* + MRO (C's bases) and never enumerates the metaclass where + contributions live; the double-type transformation is required and + is an explicit test scenario. Cost is one MRO walk per call; + cacheable later without API change. + +- Namer detector contract (canonical): an optional `is_internal_name` + attribute on the namer callable, signature `(name: str) -> bool`, + invoked on full stored attribute names. Exactly two name forms + exist, and detectors accept them as their emitted shapes dictate: + (1) static stems — `_classcore_{level}_{core}_`, trailing + underscore, no digest (e.g. `_classcore_class_behaviors_`); + (2) mangled names — stem followed immediately by exactly 64 + lowercase hexadecimal digits, NO trailing underscore after the + digest, because `utilities.mangle_name` appends the digest directly + (e.g. `_classcore_class_in_progress_` + digest; verified form: + `_classcore_class_in_progress_6e6f...7ee`). Transient mangled + names (construction markers) match form (2) while they exist. + Grammar-constrained static stems (mirroring the model downstream + confirmed for Accretive): a stem marks internal only when its level + is one of the closed set class, classes, instance, instances, and + its core is one of the enumerated set classcore generates — + behaviors, construction_arguments, dynadoc_configuration, + in_progress, mutables_names, mutables_predicates, mutables_regexes, + visibles_names, visibles_predicates, visibles_regexes, + assigner_core, deleter_core, surveyor_core. The enumeration lives + beside the namer in the same module, and a consistency test asserts + every namer call site's arguments are covered. Mangled forms: two + stems are stored through the mangling accessors and therefore carry + exactly 64 lowercase hex with no trailing underscore — + `_classcore_class_behaviors_` (class behaviors, stored via + setattr0 by the initialization completer) and + `_classcore_class_in_progress_` (the construction marker). + The static `_classcore_instance_behaviors_` form is distinct and + unmangled; instance-level storage does not use digest mangling on + standard classes (verified against Object.__dict__). Names with the prefix + that fail the grammar — `_classcore_not_a_generated_core_`, + `_classcore_class_lookalike_`, malformed digests (wrong length, + uppercase, trailing underscore), `_classcore_class_in_progress_` + bare stem — MUST NOT match. The classcore model detector is the + reference implementation of this convention. Downstream detectors + follow their own emitted shapes: Accretive requires its static-stem + forms AND its exact mangled-progress form + (`_accretive_class_in_progress_<64hex>`), because that marker is + generated by the shared classcore factory under Accretive's namer; + Frigid requires the same three-form family for `_frigid_` + (confirmed forms: `_frigid_{level}_{core}_`; + `_frigid_class_behaviors_{64hex}`; + `_frigid_class_in_progress_{64hex}`), mirroring classcore's two + mangled stems. Frigid additionally notes + transient names may appear in `__protocol_attrs__` rather than + `__dict__` post-construction; marking applies wherever the name is + explained, independent of storage location. +- `_dynadoc_fragments_` excluded everywhere: public declaration + convention, analogous to `__slots__`. +- No global state, no locking, no registry injection. Tests construct + metaclasses with contributions directly. + +## Risks / Trade-offs + +- `is_internal_name` signature change is free only pre-release; the + change targets the 1.13 window (noted in the proposal). +- A downstream namer without a detector contributes nothing — its names + stay unmarked, which is the status quo and fails safe (a false + "internal" claim is worse than a false "user" claim; the latter is + today's behavior for downstreams anyway). +- Metaclass MRO consultation on every explanation adds a small constant + cost; acceptable for a diagnostics API, cacheable if profiling ever + demands. diff --git a/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/proposal.md b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/proposal.md new file mode 100644 index 0000000..f40dbfc --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/proposal.md @@ -0,0 +1,66 @@ +## Why + +`explain_attribute` marks internal names using a hardcoded pair: the +classcore prefix and the ABC machinery set. Downstream frameworks built +on classcore (Accretive, Frigid) produce identically structured internal +attributes (`_accretive_*`, `_frigid_*` via their `calculate_attrname` +namers), so explaining a downstream class today makes false statements +about the nature of downstream attributes. The explanation API is only +as truthful as its knowledge of the ecosystem, and responsibility for +knowing a framework's names belongs with that framework's machinery. + +## What Changes + +Replace global name matching with metaclass-carried internal-name +contributions: + +- Internal-name knowledge attaches at metaclass/factory decoration + time — the classcore prefix rides classcore's factory wiring, the ABC + set rides the `AbstractClass`/`ProtocolClass` wiring (mirroring how + enforcement already scopes `class_mutables` per metaclass), and + downstream prefixes ride their `class_factory`-produced metaclasses + via namer-provided detectors. +- `explain_attribute` and `is_internal_name(target, name)` consult the + target's metaclass MRO for contributions; decorator-path classes + (built by plain `type`) carry their contribution on the class itself + and are consulted through the class resolution order. No public + factory or decorator argument is added. +- `_dynadoc_fragments_` is never marked internal: it is a documented, + user-settable declaration convention, analogous to `__slots__`. +- No global registry: no shared store, no locking, no injection + parameter. A global fixed-name fallback tier remains possible later + and additive if a real non-classcore-attached consumer appears. + +Resolved by construction: a non-ABC class defining `_abc_cache` sees it +unmarked (no ABC contribution in its metaclass chain), and a class in +one package deriving from another framework's base inherits that +framework's contribution via the metaclass. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `attribute-explanations`: Internal Attribute Marking requirement is + modified — marking is determined by metaclass-carried contributions + rather than hardcoded name sources, and the public declaration + convention `_dynadoc_fragments_` is excluded. + +## Impact + +- `sources/classcore/standard/explanations.py`: `is_internal_name` + gains a target parameter and consults metaclass contributions. +- `sources/classcore/factories.py` and/or `standard/decorators.py`: + contribution wiring at factory/decoration time. +- `sources/classcore/standard/classes.py`: ABC contribution on + `AbstractClass` wiring. +- Namer convention: optional detector exposed by `calculate_attrname` + namers (classcore's own annotated as the model implementation). +- Tests: non-ABC `_abc_cache`, multi-package inheritance, digest-suffix + shape validation, `_dynadoc_fragments_` exclusion. +- README and a towncrier fragment. + +Timing note: `is_internal_name` gains a target parameter — a signature +change that is free only while explanations have not shipped; this +change targets the 1.13 pre-release window. diff --git a/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/specs/attribute-explanations/spec.md b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/specs/attribute-explanations/spec.md new file mode 100644 index 0000000..5c9c206 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/specs/attribute-explanations/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: Internal Attribute Marking + +Explanations MUST mark attributes as internal according to +internal-name contributions carried by the machinery that constructed +the target: classcore's factory wiring contributes its framework-name +detector, the abstract-base wiring contributes the stdlib ABC machinery +set, and downstream factory-produced metaclasses contribute detectors +from their attribute namers. Contributions MUST be discovered by +consulting the target's metaclass method resolution order; a class +whose metaclass chain carries no matching contribution MUST NOT mark +the name, even when the name resembles a framework or machinery name. +The `_dynadoc_fragments_` attribute is a public declaration convention +and MUST NOT be marked internal. + +#### Scenario: Framework-internal attribute +- **WHEN** the explained name matches the classcore detector carried by + the target's metaclass chain +- **THEN** the explanation MUST be marked internal + +#### Scenario: Downstream framework attribute +- **WHEN** the target's metaclass was produced by a downstream factory + whose namer contributes a detector, and the explained name matches + that detector +- **THEN** the explanation MUST be marked internal + +#### Scenario: Machinery name without machinery +- **WHEN** a class whose metaclass chain lacks the abstract-base + contribution defines an attribute named like stdlib ABC machinery +- **THEN** the explanation MUST NOT mark the name internal + +#### Scenario: Inherited contribution across packages +- **WHEN** a class defined in one package derives from a base whose + metaclass carries a framework contribution +- **THEN** the explanation MUST apply that contribution to the derived + class + +#### Scenario: Instance target consults metaclass chain +- **WHEN** an instance target is explained and a contribution is + carried by the metaclass chain of the instance's type — discovered + through the metaclass MRO, not the class MRO of the instance's type +- **THEN** the explanation MUST apply that contribution + +#### Scenario: Shape-invalid lookalike +- **WHEN** an explained name shares a framework's prefix but fails the + grammar the framework's detector requires — a core outside the + framework's enumerated vocabulary (e.g. + `_classcore_not_a_generated_core_`), or a mangled form with a + malformed digest (wrong length, uppercase characters, or trailing + underscore) +- **THEN** the explanation MUST NOT mark the name internal + +#### Scenario: Declaration convention +- **WHEN** the explained name is `_dynadoc_fragments_` +- **THEN** the explanation MUST NOT mark the name internal + +#### Scenario: User attribute +- **WHEN** the explained name matches no contribution in the target's + metaclass chain +- **THEN** the explanation MUST NOT be marked internal diff --git a/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/tasks.md b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/tasks.md new file mode 100644 index 0000000..1ac4e09 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-12-add-internal-name-contributions/tasks.md @@ -0,0 +1,50 @@ +## 1. Namer Detector Convention + +- [x] 1.1 Annotate classcore's `calculate_attrname` namer with an + `is_internal_name` detector attribute implementing the + grammar-constrained convention: closed level set, enumerated + core set, and both exact mangled forms + (`class_behaviors` and `class_in_progress` stems with 64 + lowercase hex); prefix lookalikes that fail the grammar do not + mark +- [x] 1.2a Add a consistency test asserting every namer call site's + level and core arguments are covered by the detector grammar +- [x] 1.2 Document the namer detector convention: an optional + `is_internal_name` attribute on the namer, read by factory + wiring; no new factory/decorator arguments + + +## 2. Contribution Wiring + +- [x] 2.1 Record the namer's detector as a metaclass contribution at + factory/decoration time, inherited by produced metaclasses +- [x] 2.2 Contribute the stdlib ABC machinery set on the + `AbstractClass` wiring (mirroring `class_mutables` scoping), + attached post-decoration through a private wiring function with + no new public factory argument +- [x] 2.2a Add a decorator-path inheritance test: subclassing a + decorated class keeps internal marking via the class MRO +- [x] 2.3 Exclude `_dynadoc_fragments_` from every contribution + +## 3. Marking Consultation + +- [x] 3.1 Change `is_internal_name` to accept the target and consult + the metaclass method resolution order for contributions +- [x] 3.2 Integrate consultation into `explain_attribute`; instance + targets consult `type(type(x)).__mro__` (the metaclass MRO of + the instance's type), never `type(x).__mro__` (the class MRO, + which does not enumerate the metaclass) + +## 4. Conformance and Documentation + +- [x] 4.1 Scenario tests: framework-internal coverage of static stems + and both mangled forms (`class_behaviors` and + `class_in_progress` digests), downstream detector, machinery + name without machinery, inherited contribution across packages, + instance-target metaclass-chain consultation (proving the + double-type lookup), shape-invalid lookalikes, declaration + convention exclusion, plain user attribute +- [x] 4.2 Update the internal-marking section of the README; add the + towncrier fragment +- [x] 4.3 Full validation: make-all and the PyPy environment; strict + OpenSpec validation diff --git a/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/.openspec.yaml b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/design.md b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/design.md new file mode 100644 index 0000000..3cc4b1c --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/design.md @@ -0,0 +1,81 @@ +# Design: Export Attrname Calculator + +## Context + +`AttrnameCalculator` in `classcore.__.nomina` is the reference +implementation of the attribute-namer convention: `__call__` +generates `_{package}_{level}_{core}_` names and `is_internal_name` +detects them (plus digest-mangled forms), both closed over a +module-level `package_name`. Downstream packages (Frigid 4.4) need +the same grammar under their own prefix. A local mirror reintroduces +the drift failure mode already observed with the mutables sets. +Stakeholders: Classcore Owner (implements), Frigid Owner (consumes, +Tier-1 reviewer), Advisor (Cc, design input given). + +## Goals / Non-Goals + +Goals: one public import path for the calculator; prefix +configuration without method overrides; grammar stays single-sourced +and evolvable; no behavior change for classcore's own names. +Non-goals: no grammar snapshot exports; no shared test harness; no +changes to the contributions collection mechanism or the +`calculate_contribution_name` function. + +## Decisions + +1. **Constructor `package_name` argument (default: current module + derivation), not class attribute or factory.** Constructor + configuration makes registration-time stability structural: the + value is fixed where the namer is created, and nothing invites + later reassignment. Class-attribute form was considered (matches + framework class-attribute configuration idiom; preserves IS-A + without instantiation) but rejected: mutable attributes are + exactly what this package family avoids, and Frigid needs no + subclass at all — instantiation suffices. Factory + (`produce_attrname_calculator`) was considered and rejected: a + new public function for one value adds surface without function. +2. **Grammar containers stay private.** `_levels`, `_cores`, + `_mangled_cores`, `_hex_digits` remain module-private in + `classcore.__.nomina`. The public promise is the shared + naming/detection convention, not frozen vocabulary. Documented + as reserved framework grammar: shape conformance, not proof an + attribute was generated. +3. **Preserve the generation/detection asymmetry.** `__call__` + emits the two class-level mangling stems; the detector accepts + only their completed 64-hex digest forms, never bare stems. + Parameterization must not require + `detector( __call__( ... ) )` round-trips. +4. **Alias export, as with the mutables sets.** + `AttrnameCalculator = __.AttrnameCalculator` in + `classcore.standard.nomina`; canonical home stays + `classcore.__.nomina`. Identity (not copy) preserved. +5. **Per-project conformance tests, no shared harness.** Upstream + tests the public calculator (positive/negative/mangled cases + plus the no-override downstream-subclass form); Frigid inventories + its own call sites. The existing AST drift scan stays + per-project (it covers only bare-name literal calls). + +## Risks / Trade-offs + +- [Risk] Downstream mutates `package_name` post-registration, so + previously generated names stop matching. → Mitigation: document + as construction-time configuration; no setter or helper invites + mutation. +- [Risk] Grammar evolution breaks downstream expectations (new core + classifies additional names as internal). → Mitigation: that IS + the lockstep contract; document that classification follows the + newest grammar by construction. +- [Risk] `type`-statement-style laziness or introspection + differences. → Mitigation: none needed; plain class, no aliases + involved. + +## Migration Plan + +Additive only: existing `AttrnameCalculator()` behavior unchanged; +existing downstream pattern (subclass + overrides) keeps working. +No migration, no rollback beyond revert. + +## Open Questions + +- None for implementation. Frigid (Tier-1) may surface + consumption-shape feedback at review; Advisor may re-enter. diff --git a/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/proposal.md b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/proposal.md new file mode 100644 index 0000000..7610653 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/proposal.md @@ -0,0 +1,47 @@ +## Why + +Downstream packages need the reference attribute-name calculator to +contribute grammar-conformant internal names, but `AttrnameCalculator` +lives in private `classcore.__.nomina`. Frigid's local copy of the +related mutables sets already drifted once (the 3.15 typing-dunder +refusal); a local copy of the detector grammar would reintroduce the +same failure mode. Exporting the calculator with a configurable +package prefix lets downstream share the grammar by construction. + +## What Changes + +- Export `AttrnameCalculator` from public `classcore.standard.nomina` + (canonical home remains `classcore.__.nomina`; the export is the + identical object, as with the mutables sets). +- Add a constructor `package_name` argument (default preserves + current classcore behavior) consumed by both `__call__` and + `is_internal_name`, so downstream configures the prefix without + overriding either method. The prefix is construction-time + configuration and must not change after registration. +- Keep `_levels`, `_cores`, `_mangled_cores`, and `_hex_digits` + private; document the grammar as reserved framework vocabulary + (shape conformance, not proof of generation). +- Update the downstream-pattern test to the no-override subclass + form; per-project grammar-conformance tests live upstream, and + call-site inventories live downstream (no shared test API). + +## Capabilities + +### New Capabilities + +- `downstream-namer-calculator`: public calculator export with + configurable package prefix for downstream internal-name + contributions. + +### Modified Capabilities + +- None. No existing spec requirements change; detection behavior + for classcore's own names is unchanged. + +## Impact + +- `sources/classcore/__/nomina.py` (prefix seam, grammar + documentation), `sources/classcore/standard/nomina.py` + (re-export), downstream-pattern tests. +- Downstream: Frigid 4.4 consumes the export instead of mirroring + the grammar; no classcore runtime behavior changes. diff --git a/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/specs/downstream-namer-calculator/spec.md b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/specs/downstream-namer-calculator/spec.md new file mode 100644 index 0000000..c1e30ce --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/specs/downstream-namer-calculator/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Public calculator export + +`AttrnameCalculator` SHALL be importable from public +`classcore.standard.nomina` and SHALL be identically the object +defined in `classcore.__.nomina` (canonical home unchanged). + +#### Scenario: Downstream imports without private path + +- **WHEN** a downstream package imports `AttrnameCalculator` from + `classcore.standard.nomina` +- **THEN** the import succeeds and the object is identically + `classcore.__.nomina.AttrnameCalculator`. + +### Requirement: Configurable package prefix + +The calculator SHALL accept a `package_name` constructor argument +defaulting to current classcore behavior. Both `__call__` and +`is_internal_name` SHALL resolve the same configured prefix, and +the prefix SHALL be treated as construction-time configuration. + +#### Scenario: Default preserves classcore behavior + +- **WHEN** `AttrnameCalculator()` is constructed without arguments +- **THEN** `__call__` emits `_{classcore}_{level}_{core}_` names and + `is_internal_name` detects exactly the names it detected before + this change. + +#### Scenario: Downstream prefix without overrides + +- **WHEN** a downstream package constructs + `AttrnameCalculator( package_name = 'frigid' )` (or subclasses + without overriding either method) +- **THEN** `__call__` emits `_frigid_` names and + `is_internal_name` detects grammar-conformant `_frigid_` names + while rejecting `_classcore_` names and non-grammar lookalikes + such as `_frigid_custom_`. + +#### Scenario: Prefix mismatch does not match + +- **WHEN** names are generated under one prefix and checked by a + detector configured with another prefix +- **THEN** they do not match. Prefix configuration is + construction-time; post-registration mutation is unsupported. + +### Requirement: Private grammar vocabulary + +`_levels`, `_cores`, `_mangled_cores`, and `_hex_digits` SHALL +remain private to `classcore.__.nomina`. Documentation SHALL state +the grammar is reserved framework vocabulary (shape conformance), +not proof an attribute was generated. + +#### Scenario: Grammar evolves without downstream changes + +- **WHEN** classcore adds a core to the private grammar +- **THEN** downstream calculators constructed as specified detect + the new core's names with no downstream modification. + +### Requirement: Generation-detection asymmetry preserved + +`__call__` SHALL continue to emit the two class-level mangling +stems, and the detector SHALL continue to accept only their +completed 64-hex digest forms, never bare stems, under any +configured prefix. + +#### Scenario: Mangled forms under downstream prefix + +- **WHEN** a downstream calculator detects + `_frigid_class_behaviors_<64 hex>` and `_frigid_class_behaviors_` +- **THEN** the digest form is internal and the bare stem is not. diff --git a/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/tasks.md b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/tasks.md new file mode 100644 index 0000000..56fac89 --- /dev/null +++ b/documentation/architecture/openspec/changes/archive/2026-10-16-export-attrname-calculator/tasks.md @@ -0,0 +1,30 @@ +## 1. Calculator prefix seam + +- [x] 1.1 Add `package_name` constructor argument to + `AttrnameCalculator` (default preserves classcore behavior) and + resolve it via `self` in `__call__` and `is_internal_name`. +- [x] 1.2 Document the prefix as construction-time configuration, + the reserved-grammar vocabulary, and the + generation-detection asymmetry in the class docstring. + +## 2. Public export + +- [x] 2.1 Re-export `AttrnameCalculator` from + `classcore.standard.nomina` (alias of the hub-canonical object). +- [x] 2.2 Add identity test (`is` hub object) plus default-behavior + conformance to the internals tests. + +## 3. Downstream-pattern tests + +- [x] 3.1 Rework the downstream-pattern test to the no-override + form (constructor-configured or subclassed prefix, both methods + untouched): positive, negative (`_frigid_custom_`), and mangled + conformance cases. +- [x] 3.2 Verify per-project AST drift scan still passes + unchanged. + +## 4. Release artifacts + +- [x] 4.1 Add towncrier `enhance` fragment for the public export. +- [x] 4.2 Validate full gates (make-all, QA matrix, strict + OpenSpec) and route Tier-1 (Reviewer General + Frigid Owner). diff --git a/documentation/architecture/openspec/config.yaml b/documentation/architecture/openspec/config.yaml new file mode 100644 index 0000000..f9153b7 --- /dev/null +++ b/documentation/architecture/openspec/config.yaml @@ -0,0 +1,19 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: python +# Project: python-classcore + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/documentation/architecture/openspec/specs/.gitkeep b/documentation/architecture/openspec/specs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/documentation/architecture/openspec/specs/abc-support/spec.md b/documentation/architecture/openspec/specs/abc-support/spec.md new file mode 100644 index 0000000..8871398 --- /dev/null +++ b/documentation/architecture/openspec/specs/abc-support/spec.md @@ -0,0 +1,87 @@ +# abc-support Specification + +## Purpose + +Opt-in abstract base class support: combine standard behaviors with +`abc.ABCMeta` machinery for abstract classes that mix with external +ABC-based classes, and unify the protocol metaclass taxonomy under +`Class`. + +## Requirements + +### Requirement: Abstract Class Metaclass + +The `AbstractClass` metaclass MUST combine the standard behaviors of +`Class` with `abc.ABCMeta` via a diamond inheritance hierarchy. `Class` +itself MUST remain backed by plain `type` so that ordinary classcore +classes carry no ABC machinery. + +#### Scenario: Abstract class creation +- **WHEN** a class uses the `AbstractClass` metaclass +- **THEN** the resulting class MUST support standard behaviors + (immutability, concealment) and ABC machinery (abstract method + enforcement, virtual subclass registration) + +#### Scenario: Metaclass hierarchy +- **WHEN** `AbstractClass` is inspected +- **THEN** it MUST be a subclass of both `Class` and `abc.ABCMeta` + +#### Scenario: Ordinary classes unaffected +- **WHEN** a class uses the `Class` metaclass +- **THEN** it MUST NOT gain ABC machinery + +### Requirement: Abstract Base Class + +`AbstractObject` MUST serve as a base class for abstract classes with +standard behaviors, created with `AbstractClass` and exempting ABC cache +attributes from class immutability. + +#### Scenario: Inheritance +- **WHEN** a class inherits from `AbstractObject` +- **THEN** the class MUST be created with `AbstractClass` as its + metaclass, with standard behaviors active + +#### Scenario: Mixing with external abstract base classes +- **WHEN** a class inherits from both `AbstractObject` and an external + class whose metaclass is `abc.ABCMeta` +- **THEN** class creation MUST succeed without + `TypeError: metaclass conflict` + +#### Scenario: Abstract method enforcement +- **WHEN** a class inherits abstract methods from an external ABC and + does not implement them +- **THEN** instantiation MUST raise `TypeError` +- **AND** a subclass implementing all abstract methods MUST instantiate + +#### Scenario: Virtual subclass registration +- **WHEN** `register()` is called on a class derived from + `AbstractObject` +- **THEN** the registered class MUST satisfy `isinstance()` checks +- **AND** ABC cache mutations MUST NOT raise immutability errors + +### Requirement: Unified Protocol Metaclass + +`ProtocolClass` MUST inherit from both `AbstractClass` and the metaclass +of `typing.Protocol`, unifying the metaclass taxonomy under `Class`. + +#### Scenario: Unified taxonomy +- **WHEN** `ProtocolClass` is inspected +- **THEN** it MUST be a subclass of `Class`, `AbstractClass`, and + `abc.ABCMeta` + +#### Scenario: Protocol functionality preserved +- **WHEN** the `protocol-support` capability scenarios are exercised +- **THEN** runtime protocol detection, `@runtime_checkable`, and + `isinstance()` structural subtyping MUST continue to behave as + specified + +#### Scenario: Single decoration through hierarchy +- **WHEN** a class is created through the unified protocol metaclass + hierarchy with a decorator +- **THEN** the decorator MUST be applied exactly once + +#### Scenario: Concrete classes structurally satisfy protocols +- **WHEN** a concrete classcore class (e.g., backed by `Class` or + `AbstractClass`) implements all members of a `@runtime_checkable` + classcore protocol +- **THEN** `isinstance()` MUST return `True` for that protocol diff --git a/documentation/architecture/openspec/specs/attribute-concealment/spec.md b/documentation/architecture/openspec/specs/attribute-concealment/spec.md new file mode 100644 index 0000000..2b7ef29 --- /dev/null +++ b/documentation/architecture/openspec/specs/attribute-concealment/spec.md @@ -0,0 +1,81 @@ +# Attribute Concealment + +## Purpose + +The library SHALL provide mechanisms to conceal non-public attributes from +`dir()` output, reducing API noise and clearly communicating public interfaces. + +## Requirements + +### Requirement: Default Visibility Filtering + +By default, only public attributes (not starting with `_`) MUST be visible +in `dir()` output for classes and instances using standard behaviors. + +#### Scenario: Public attributes visible +- **WHEN** `dir()` is called on a class or instance with standard behaviors +- **THEN** attributes not starting with `_` MUST appear in the result + +#### Scenario: Non-public attributes concealed +- **WHEN** `dir()` is called on a class or instance with standard behaviors +- **THEN** attributes starting with `_` MUST NOT appear in the result + +#### Scenario: Non-public attributes still accessible +- **WHEN** a concealed attribute is accessed directly by name +- **THEN** the attribute MUST be accessible + +### Requirement: Class Visibility Customization + +Users MUST be able to specify which class attributes are visible via +`class_visibles`. + +#### Scenario: Named visible attribute +- **WHEN** an attribute name is listed in `class_visibles` +- **THEN** that attribute MUST appear in `dir()` even if non-public + +#### Scenario: Non-visible attribute +- **WHEN** an attribute name is NOT in `class_visibles` +- **THEN** concealment rules apply normally + +### Requirement: Instance Visibility Customization + +Users MUST be able to specify which instance attributes are visible via +`instances_visibles`. + +#### Scenario: Named visible instance attribute +- **WHEN** an attribute name is listed in `instances_visibles` +- **THEN** that attribute MUST appear in `dir()` even if non-public + +### Requirement: Visibility Verifiers + +Visibility verifiers MUST support names, regexes, and predicates, matching +the mutability verifier interface. + +#### Scenario: Regex-based visibility +- **WHEN** an attribute name matches a regex in `class_visibles` or `instances_visibles` +- **THEN** that attribute MUST appear in `dir()` + +#### Scenario: Predicate-based visibility +- **WHEN** a predicate returns `True` for an attribute name +- **THEN** that attribute MUST appear in `dir()` + +### Requirement: Wildcard Visibility + +Users MUST be able to disable concealment entirely using a literal `'*'`. + +#### Scenario: Wildcard shows all attributes +- **WHEN** `class_visibles` or `instances_visibles` is set to `'*'` +- **THEN** all attributes MUST appear in `dir()` (Python default behavior) + +### Requirement: Visibility Verifier Inheritance + +Visibility verifiers MUST be inherited and composable across class +hierarchies. + +#### Scenario: Inherited visibility names +- **WHEN** a subclass inherits from a class with custom visibles +- **THEN** the subclass MUST inherit those visible names + +#### Scenario: Composed visibility names +- **WHEN** a subclass defines additional visibles +- **THEN** both parent and child visibles MUST be active diff --git a/documentation/architecture/openspec/specs/attribute-explanations/spec.md b/documentation/architecture/openspec/specs/attribute-explanations/spec.md new file mode 100644 index 0000000..d2ec40b --- /dev/null +++ b/documentation/architecture/openspec/specs/attribute-explanations/spec.md @@ -0,0 +1,184 @@ +# attribute-explanations Specification + +## Purpose + +Explain attribute behavior decisions: expose the decision cascade — +active behaviors, per-operation verdicts, and the single decision that +determined each outcome — as inspectable, immutable records, so users +and tooling can ask why an attribute is mutable or immutable, visible +or concealed. +## Requirements +### Requirement: Attribute Explanation + +The `explain_attribute` function MUST return the decision trace for the +named attribute on the target: the active behaviors for each evaluated +level, and for each operation the applicable verdict. Every verdict MUST +carry exactly one decision, drawn from a closed hierarchy: permitted by +inapplicability (the governing behavior is inactive), permitted by the +omni marker, permitted by names membership, permitted by a predicate, +permitted by a regex, or prohibited (the behavior is active and no rule +permits). All three operations — assign, delete, and survey — MUST +apply the same precedence semantics: omni, then names membership, then +the first matching predicate, then the first matching regex. + +For class targets, the classes-level configuration MUST be evaluated. +For instance targets, the instances-level configuration MUST be +evaluated against the instance's class hierarchy. Survey verdicts MUST +explain the visibility of the supplied name only. Explanations MUST be +observational: they MUST NOT bypass or alter concealment or +immutability. + +Note: survey explanations describe the normalized first-match semantics. +The survey core currently yields a name once per matching rule; that +duplication is characterized as a defect and deferred to a separate +repair, after which core behavior and explanation semantics coincide. + +#### Scenario: Mutable attribute +- **WHEN** the active behaviors for the evaluated level lack + immutability +- **THEN** the assign and delete verdicts MUST be permitted by + inapplicability + +#### Scenario: Excluded by name +- **WHEN** the attribute name is in the exclusion names for the + operation's level +- **THEN** the verdict MUST be permitted by names, carrying the matched + name + +#### Scenario: Excluded by regex +- **WHEN** an exclusion regex fullmatches the attribute name +- **THEN** the verdict MUST be permitted by regex, carrying the pattern + text + +#### Scenario: Excluded by predicate +- **WHEN** an exclusion predicate matches the attribute name +- **THEN** the verdict MUST be permitted by predicate, carrying the + predicate's fully-qualified name, or `` when unavailable + +#### Scenario: Omni exclusion +- **WHEN** the exclusion names for assign or delete equal `'*'` +- **THEN** the verdict MUST be permitted by omni + +#### Scenario: Immutable attribute +- **WHEN** immutability is active and no rule permits the operation +- **THEN** the assign and delete verdicts MUST be prohibited + +#### Scenario: Concealed attribute +- **WHEN** concealment is active and no visibles rule matches the name +- **THEN** the survey verdict MUST be prohibited + +#### Scenario: Survey multi-rule precedence +- **WHEN** the attribute name matches a predicate and a regex and is not + in the exclusion names +- **THEN** the survey verdict MUST be permitted by the predicate, the + first rule under precedence, and MUST NOT carry the regex + +#### Scenario: Survey names precedence +- **WHEN** the attribute name is in the exclusion names for the survey + level and also matches a predicate or regex +- **THEN** the survey verdict MUST be permitted by names and predicates + and regexes MUST NOT be evaluated for that name + +#### Scenario: Survey omni +- **WHEN** concealment is active and the visibles names equal `'*'` +- **THEN** the survey verdict MUST be permitted by omni and predicates + and regexes MUST NOT be evaluated + +#### Scenario: Survey concealment inactive +- **WHEN** the active behaviors for the evaluated level lack concealment +- **THEN** the survey verdict MUST be permitted by inapplicability + +#### Scenario: Permissible derivation +- **WHEN** any verdict's decision is not prohibited +- **THEN** the verdict MUST report itself as permissible + +### Requirement: Decision Payload Distinction + +Decision payloads MUST be distinguished by type, so that a name which +coincides with a genus label or pattern text cannot be confused with +it. The names decision MUST carry the matched name; the predicate +decision MUST carry qualified-name text; the regex decision MUST carry +pattern text. Payloads MUST be plain text. + +#### Scenario: Names payload collision +- **WHEN** the explained attribute is literally named `regex` and is + permitted by names membership +- **THEN** the decision MUST be a names decision carrying the name + `regex`, distinct from any regex decision carrying pattern text + +### Requirement: Record Immutability + +The explanation records MUST be immutable: the decision hierarchy, the +verdicts, and `AttributeExplanation` MUST NOT permit field assignment, +and every nested collection they contain MUST be an immutable +structure. Verdict permissibility MUST be derived from the decision, +not stored. + +#### Scenario: Frozen records +- **WHEN** a field is assigned on any explanation record +- **THEN** an immutability error MUST be raised + +#### Scenario: Immutable nested collections +- **WHEN** the operations mapping, the behaviors mapping, or a payload + sequence is mutated +- **THEN** an error MUST be raised + +### Requirement: Internal Attribute Marking + +Explanations MUST mark attributes as internal according to +internal-name contributions carried by the machinery that constructed +the target: classcore's factory wiring contributes its framework-name +detector, the abstract-base wiring contributes the stdlib ABC machinery +set, and downstream factory-produced metaclasses contribute detectors +from their attribute namers. Contributions MUST be discovered by +consulting the target's metaclass method resolution order; a class +whose metaclass chain carries no matching contribution MUST NOT mark +the name, even when the name resembles a framework or machinery name. +The `_dynadoc_fragments_` attribute is a public declaration convention +and MUST NOT be marked internal. + +#### Scenario: Framework-internal attribute +- **WHEN** the explained name matches the classcore detector carried by + the target's metaclass chain +- **THEN** the explanation MUST be marked internal + +#### Scenario: Downstream framework attribute +- **WHEN** the target's metaclass was produced by a downstream factory + whose namer contributes a detector, and the explained name matches + that detector +- **THEN** the explanation MUST be marked internal + +#### Scenario: Machinery name without machinery +- **WHEN** a class whose metaclass chain lacks the abstract-base + contribution defines an attribute named like stdlib ABC machinery +- **THEN** the explanation MUST NOT mark the name internal + +#### Scenario: Inherited contribution across packages +- **WHEN** a class defined in one package derives from a base whose + metaclass carries a framework contribution +- **THEN** the explanation MUST apply that contribution to the derived + class + +#### Scenario: Instance target consults metaclass chain +- **WHEN** an instance target is explained and a contribution is + carried by the metaclass chain of the instance's type — discovered + through the metaclass MRO, not the class MRO of the instance's type +- **THEN** the explanation MUST apply that contribution + +#### Scenario: Shape-invalid lookalike +- **WHEN** an explained name shares a framework's prefix but fails the + grammar the framework's detector requires — a core outside the + framework's enumerated vocabulary (e.g. + `_classcore_not_a_generated_core_`), or a mangled form with a + malformed digest (wrong length, uppercase characters, or trailing + underscore) +- **THEN** the explanation MUST NOT mark the name internal + +#### Scenario: Declaration convention +- **WHEN** the explained name is `_dynadoc_fragments_` +- **THEN** the explanation MUST NOT mark the name internal + +#### Scenario: User attribute +- **WHEN** the explained name matches no contribution in the target's + metaclass chain +- **THEN** the explanation MUST NOT be marked internal diff --git a/documentation/architecture/openspec/specs/attribute-immutability/spec.md b/documentation/architecture/openspec/specs/attribute-immutability/spec.md new file mode 100644 index 0000000..bbb7a55 --- /dev/null +++ b/documentation/architecture/openspec/specs/attribute-immutability/spec.md @@ -0,0 +1,96 @@ +# Attribute Immutability + +## Purpose + +The library SHALL provide mechanisms to make class and instance attributes +immutable after initialization, with support for selective mutability via +names, regexes, or predicates. + +## Requirements + +### Requirement: Class Attribute Immutability + +Class attributes produced by standard metaclasses MUST be immutable after +class construction completes. + +#### Scenario: Assignment to immutable class attribute +- **WHEN** assignment is attempted on a class attribute after construction +- **THEN** `AttributeImmutability` MUST be raised + +#### Scenario: Deletion of immutable class attribute +- **WHEN** deletion is attempted on a class attribute +- **THEN** `AttributeImmutability` MUST be raised + +### Requirement: Instance Attribute Immutability + +Instance attributes of standard classes MUST be immutable after `__init__` +and `__post_init__` complete. + +#### Scenario: Assignment to immutable instance attribute +- **WHEN** assignment is attempted on an instance attribute after initialization +- **THEN** `AttributeImmutability` MUST be raised + +#### Scenario: Deletion of immutable instance attribute +- **WHEN** deletion is attempted on an instance attribute +- **THEN** `AttributeImmutability` MUST be raised + +#### Scenario: Mutability during initialization +- **WHEN** attributes are assigned within `__init__` or `__post_init__` +- **THEN** assignment MUST succeed + +### Requirement: Selective Mutability by Name + +Users MUST be able to specify mutable attributes by exact name. + +#### Scenario: Named mutable attribute +- **WHEN** an attribute name is listed in `class_mutables` or `instances_mutables` +- **THEN** that attribute MUST be mutable + +#### Scenario: Non-mutable attribute with name-based exceptions +- **WHEN** an attribute name is NOT in the mutables list +- **THEN** immutability MUST be enforced + +### Requirement: Selective Mutability by Regex + +Users MUST be able to specify mutable attributes by compiled regex pattern. + +#### Scenario: Regex-matched mutable attribute +- **WHEN** an attribute name matches a regex in `class_mutables` or `instances_mutables` +- **THEN** that attribute MUST be mutable + +#### Scenario: Regex non-match +- **WHEN** an attribute name does not match any regex +- **THEN** immutability MUST be enforced + +### Requirement: Selective Mutability by Predicate + +Users MUST be able to specify mutable attributes by predicate function. + +#### Scenario: Predicate-matched mutable attribute +- **WHEN** a predicate function returns `True` for an attribute name +- **THEN** that attribute MUST be mutable + +#### Scenario: Predicate non-match +- **WHEN** all predicates return `False` for an attribute name +- **THEN** immutability MUST be enforced + +### Requirement: Wildcard Mutability + +Users MUST be able to disable immutability entirely using a literal `'*'`. + +#### Scenario: Wildcard disables immutability +- **WHEN** `class_mutables` or `instances_mutables` is set to `'*'` +- **THEN** all attributes of that level MUST be mutable + +### Requirement: Mutability Verifier Inheritance + +Mutability verifiers (names, regexes, predicates) MUST be inherited and +composable across class hierarchies. + +#### Scenario: Inherited mutability names +- **WHEN** a subclass inherits from a class with named mutables +- **THEN** the subclass MUST inherit those mutable names + +#### Scenario: Composed mutability names +- **WHEN** a subclass defines additional mutables +- **THEN** both parent and child mutables MUST be active diff --git a/documentation/architecture/openspec/specs/class-factories/spec.md b/documentation/architecture/openspec/specs/class-factories/spec.md new file mode 100644 index 0000000..b1e5ceb --- /dev/null +++ b/documentation/architecture/openspec/specs/class-factories/spec.md @@ -0,0 +1,96 @@ +# Class Factories + +## Purpose + +The library SHALL provide factory functions and hook systems for users to +create custom metaclasses, behaviors, and decorators with full control over +class construction and initialization lifecycles. + +## Requirements + +### Requirement: Metaclass Constructor Factory + +The `produce_class_constructor` factory MUST generate customizable `__new__` +implementations with hook support. + +#### Scenario: Custom constructor with hooks +- **WHEN** `produce_class_constructor` is called with preprocessor and postprocessor hooks +- **THEN** the resulting constructor MUST invoke hooks at the appropriate lifecycle points + +### Requirement: Metaclass Initializer Factory + +The `produce_class_initializer` factory MUST generate customizable `__init__` +implementations with hook support. + +#### Scenario: Custom initializer with completers +- **WHEN** `produce_class_initializer` is called with completer hooks +- **THEN** the resulting initializer MUST invoke completers after initialization + +### Requirement: Construction Preprocessors + +Preprocessors MUST be able to modify class construction inputs (bases, +namespace, arguments, decorators) before class creation. + +#### Scenario: Preprocessor modifies bases +- **WHEN** a preprocessor modifies the bases tuple +- **THEN** the class MUST be created with the modified bases + +#### Scenario: Preprocessor modifies namespace +- **WHEN** a preprocessor modifies the namespace dict +- **THEN** the class MUST be created with the modified namespace + +### Requirement: Construction Postprocessors + +Postprocessors MUST be able to augment constructed classes after creation. + +#### Scenario: Postprocessor adds attributes +- **WHEN** a postprocessor adds attributes to the class +- **THEN** the class MUST have those attributes + +### Requirement: Initialization Completers + +Completers MUST be able to finalize initialized classes (e.g., freezing +behaviors) after `__init__` completes. + +#### Scenario: Completer runs after init +- **WHEN** `__init__` completes +- **THEN** completers MUST execute + +### Requirement: Decoration Preparers + +Preparers MUST be able to adjust decorator lists before application. + +#### Scenario: Preparer modifies decorators +- **WHEN** a preparer modifies the decorator list +- **THEN** the modified decorators MUST be applied + +### Requirement: Core Function Replacement + +Critical behaviors (assignment, deletion, visibility) MUST be replaceable +by providing alternative core functions. + +#### Scenario: Custom assigner +- **WHEN** a custom assigner core function is provided +- **THEN** attribute assignment MUST use the custom function + +#### Scenario: Custom surveyor +- **WHEN** a custom surveyor core function is provided +- **THEN** `dir()` MUST use the custom function + +### Requirement: Decorator Composition + +The `decoration_by` function MUST compose multiple decorators with +preparation hooks. + +#### Scenario: Composed decorators +- **WHEN** `decoration_by` is called with multiple decorators +- **THEN** all decorators MUST be applied in sequence + +### Requirement: Custom Error Classes + +Users MUST be able to provide custom error class providers for +immutability violations. + +#### Scenario: Custom error raised +- **WHEN** a custom error class provider is configured +- **THEN** immutability violations MUST raise the custom error diff --git a/documentation/architecture/openspec/specs/dataclass-integration/spec.md b/documentation/architecture/openspec/specs/dataclass-integration/spec.md new file mode 100644 index 0000000..7625d0f --- /dev/null +++ b/documentation/architecture/openspec/specs/dataclass-integration/spec.md @@ -0,0 +1,75 @@ +# Dataclass Integration + +## Purpose + +The library SHALL automatically convert classes to dataclasses while +applying immutability and concealment, preserving all dataclass functionality +including `__post_init__` attribute modification. + +## Requirements + +### Requirement: Metaclass Auto-Conversion + +The `Dataclass` metaclass MUST automatically apply `@dataclass` decorator +during class construction. + +#### Scenario: Dataclass produced by metaclass +- **WHEN** a class uses the `Dataclass` metaclass +- **THEN** the resulting class MUST be a dataclass with standard behaviors + +#### Scenario: DataclassObject base class +- **WHEN** a class inherits from `DataclassObject` +- **THEN** the resulting class MUST be a dataclass with standard behaviors + +### Requirement: Decorator-Based Creation + +The `dataclass_with_standard_behaviors` decorator MUST create a dataclass +with standard behaviors applied. + +#### Scenario: Decorated dataclass +- **WHEN** `@dataclass_with_standard_behaviors()` is applied to a class +- **THEN** the resulting class MUST be a dataclass with immutability and concealment + +### Requirement: Post-Init Modification + +`__post_init__` MUST be able to modify attributes before immutability is +enforced. + +#### Scenario: Post-init attribute assignment +- **WHEN** `__post_init__` assigns attributes +- **THEN** assignment MUST succeed + +#### Scenario: Post-init immutability enforcement +- **WHEN** `__post_init__` completes +- **THEN** attributes MUST become immutable + +### Requirement: Dataclass Argument Compatibility + +Standard metaclasses and decorators MUST be compatible with `dataclass` +arguments including `frozen`, `slots`, `kw_only`, etc. + +#### Scenario: Slots mode +- **WHEN** `slots=True` is passed to a standard dataclass metaclass +- **THEN** the resulting class MUST use `__slots__` and maintain behaviors + +#### Scenario: Kw-only mode +- **WHEN** `kw_only=True` is passed to a standard dataclass metaclass +- **THEN** all fields MUST be keyword-only + +### Requirement: Class Replacement Handling + +When `slots=True` causes class replacement, the library MUST properly +handle closure repair so that `super()` continues to function. + +#### Scenario: Super after class replacement +- **WHEN** a dataclass with `slots=True` replaces the class +- **THEN** `super()` calls in methods MUST continue to work correctly + +### Requirement: Type Checker Recognition + +Metaclasses MUST use `@dataclass_transform` to signal dataclass behavior +to type checkers. + +#### Scenario: Type checker infers dataclass +- **WHEN** a class uses a standard dataclass metaclass +- **THEN** type checkers MUST recognize it as a dataclass diff --git a/documentation/architecture/openspec/specs/downstream-namer-calculator/spec.md b/documentation/architecture/openspec/specs/downstream-namer-calculator/spec.md new file mode 100644 index 0000000..eb4303b --- /dev/null +++ b/documentation/architecture/openspec/specs/downstream-namer-calculator/spec.md @@ -0,0 +1,77 @@ +# downstream-namer-calculator Specification + +## Purpose + +Public reference calculator letting downstream packages share +classcore's internal-name grammar by construction instead of +mirroring it. +## Requirements +### Requirement: Public calculator export + +`AttrnameCalculator` SHALL be importable from public +`classcore.standard.nomina` and SHALL be identically the object +defined in `classcore.__.nomina` (canonical home unchanged). + +#### Scenario: Downstream imports without private path + +- **WHEN** a downstream package imports `AttrnameCalculator` from + `classcore.standard.nomina` +- **THEN** the import succeeds and the object is identically + `classcore.__.nomina.AttrnameCalculator`. + +### Requirement: Configurable package prefix + +The calculator SHALL accept a `package_name` constructor argument +defaulting to current classcore behavior. Both `__call__` and +`is_internal_name` SHALL resolve the same configured prefix, and +the prefix SHALL be treated as construction-time configuration. + +#### Scenario: Default preserves classcore behavior + +- **WHEN** `AttrnameCalculator()` is constructed without arguments +- **THEN** `__call__` emits `_{classcore}_{level}_{core}_` names and + `is_internal_name` detects exactly the names it detected before + this change. + +#### Scenario: Downstream prefix without overrides + +- **WHEN** a downstream package constructs + `AttrnameCalculator( package_name = 'frigid' )` (or subclasses + without overriding either method) +- **THEN** `__call__` emits `_frigid_` names and + `is_internal_name` detects grammar-conformant `_frigid_` names + while rejecting `_classcore_` names and non-grammar lookalikes + such as `_frigid_custom_`. + +#### Scenario: Prefix mismatch does not match + +- **WHEN** names are generated under one prefix and checked by a + detector configured with another prefix +- **THEN** they do not match. Prefix configuration is + construction-time; post-registration mutation is unsupported. + +### Requirement: Private grammar vocabulary + +`_levels`, `_cores`, `_mangled_cores`, and `_hex_digits` SHALL +remain private to `classcore.__.nomina`. Documentation SHALL state +the grammar is reserved framework vocabulary (shape conformance), +not proof an attribute was generated. + +#### Scenario: Grammar evolves without downstream changes + +- **WHEN** classcore adds a core to the private grammar +- **THEN** downstream calculators constructed as specified detect + the new core's names with no downstream modification. + +### Requirement: Generation-detection asymmetry preserved + +`__call__` SHALL continue to emit the two class-level mangling +stems, and the detector SHALL continue to accept only their +completed 64-hex digest forms, never bare stems, under any +configured prefix. + +#### Scenario: Mangled forms under downstream prefix + +- **WHEN** a downstream calculator detects + `_frigid_class_behaviors_<64 hex>` and `_frigid_class_behaviors_` +- **THEN** the digest form is internal and the bare stem is not. diff --git a/documentation/architecture/openspec/specs/module-reclassification/spec.md b/documentation/architecture/openspec/specs/module-reclassification/spec.md new file mode 100644 index 0000000..d050734 --- /dev/null +++ b/documentation/architecture/openspec/specs/module-reclassification/spec.md @@ -0,0 +1,63 @@ +# Module Reclassification + +## Purpose + +The library SHALL apply immutability and concealment to module objects, +enabling package-level API control and protection of module namespaces. + +## Requirements + +### Requirement: Single Module Reclassification + +The `reclassify_module` function MUST apply immutability and concealment to +a single module object. + +#### Scenario: Module attributes become immutable +- **WHEN** `reclassify_module` is applied to a module +- **THEN** module attributes MUST become immutable + +#### Scenario: Non-public module attributes concealed +- **WHEN** `dir()` is called on a reclassified module +- **THEN** non-public attributes MUST NOT appear + +#### Scenario: Public module API visible +- **WHEN** `dir()` is called on a reclassified module +- **THEN** public attributes MUST appear + +### Requirement: Module Finalization + +The `finalize_module` function MUST apply behaviors and dynadoc +configuration to complete module initialization. + +#### Scenario: Finalized module has behaviors +- **WHEN** `finalize_module` is applied to a module +- **THEN** the module MUST have immutability and concealment + +#### Scenario: Finalized module has documentation +- **WHEN** `finalize_module` is applied to a module +- **THEN** dynadoc configuration MUST be applied + +### Requirement: Recursive Application + +Module reclassification MUST support recursive application to submodules. + +#### Scenario: Recursive reclassification +- **WHEN** `reclassify_modules` is called with `recursive=True` +- **THEN** all submodules MUST be reclassified + +### Requirement: Module Attribute Immutability + +After reclassification, module attributes MUST NOT be modifiable. + +#### Scenario: Assignment to reclassified module attribute +- **WHEN** assignment is attempted on a reclassified module attribute +- **THEN** an error MUST be raised + +### Requirement: Module Attribute Concealment + +After reclassification, non-public module attributes MUST be concealed +from `dir()` output. + +#### Scenario: Concealed module attribute +- **WHEN** `dir()` is called on a reclassified module +- **THEN** attributes starting with `_` MUST NOT appear (unless explicitly visible) diff --git a/documentation/architecture/openspec/specs/protocol-support/spec.md b/documentation/architecture/openspec/specs/protocol-support/spec.md new file mode 100644 index 0000000..6be0212 --- /dev/null +++ b/documentation/architecture/openspec/specs/protocol-support/spec.md @@ -0,0 +1,111 @@ +# Protocol Support + +## Purpose + +The library SHALL provide protocol classes with immutability and concealment +that work with structural subtyping, preserving protocol functionality +including runtime checks. +## Requirements +### Requirement: Protocol Metaclass + +The protocol metaclasses MUST combine `typing.Protocol` with standard +behaviors and MUST form a proper inheritance hierarchy. + +#### Scenario: Protocol class creation +- **WHEN** a class uses a protocol metaclass +- **THEN** the resulting class MUST support structural subtyping with + immutability and concealment + +#### Scenario: Metaclass hierarchy +- **WHEN** `ProtocolDataclass` and `ProtocolDataclassMutable` are inspected +- **THEN** `ProtocolDataclass` MUST be a subclass of `ProtocolClass` +- **AND** `ProtocolDataclassMutable` MUST be a subclass of `ProtocolDataclass` + +#### Scenario: Cross-metaclass inheritance +- **WHEN** a class inherits from bases backed by different protocol + metaclasses (e.g., `Protocol` and `DataclassProtocol`) +- **THEN** class creation MUST succeed without `TypeError: metaclass conflict` + +#### Scenario: Dataclass transform preservation +- **WHEN** `ProtocolDataclassMutable` inherits from `ProtocolDataclass` +- **THEN** `ProtocolDataclassMutable.__dataclass_transform__` MUST have + `frozen_default = False` +- **AND** the value MUST be in `ProtocolDataclassMutable.__dict__`, not + inherited from `ProtocolDataclass` + +### Requirement: Protocol Base Classes + +The protocol base classes MUST serve as bases for protocol definitions +with standard behaviors. The base classes are `Protocol`, +`ProtocolMutable`, `DataclassProtocol`, and `DataclassProtocolMutable`. + +#### Scenario: Protocol inheritance +- **WHEN** a class inherits from a protocol base class +- **THEN** the resulting class MUST be a protocol with immutability and + concealment + +### Requirement: Structural Subtyping Preservation + +Protocol functionality MUST be preserved, including structural subtyping +and runtime checks. + +#### Scenario: Structural subtyping +- **WHEN** a class implements all protocol methods +- **THEN** `isinstance()` MUST return `True` for that protocol + +#### Scenario: Runtime checkable protocols +- **WHEN** a protocol is decorated with `@runtime_checkable` +- **THEN** `isinstance()` checks MUST work correctly + +### Requirement: ABC Attribute Exemptions + +ABC-related mutable attributes (`_abc_cache`, `_abc_registry`, etc.) MUST +be allowed by default to ensure protocol functionality. + +#### Scenario: ABC cache mutation +- **WHEN** Python internals modify `_abc_cache` on a protocol class +- **THEN** no immutability error MUST be raised + +### Requirement: Implementation Inheritance + +Immutability and concealment MUST be inherited by classes implementing +protocol interfaces. + +#### Scenario: Implementation inherits behaviors +- **WHEN** a class implements a protocol with standard behaviors +- **THEN** the implementation MUST inherit immutability and concealment + +### Requirement: Runtime Protocol Recognition + +Direct subclasses of classcore protocol base classes MUST be recognized as +protocols at runtime. The `_is_protocol` attribute MUST be set to `True` on +classes that directly inherit from a classcore protocol base class +(`Protocol`, `ProtocolMutable`, `DataclassProtocol`, +`DataclassProtocolMutable`). Concrete implementation subclasses (subclasses +of a protocol that do not include a protocol base class in their own +`__bases__`) MUST have `_is_protocol` set to `False`, matching standard +`typing.Protocol` semantics. + +#### Scenario: Direct subclass recognized as protocol +- **WHEN** a class directly inherits from a classcore protocol base class +- **THEN** `cls._is_protocol` MUST be `True` + +#### Scenario: Concrete implementation not recognized as protocol +- **WHEN** a class inherits from a protocol subclass without including a + protocol base class in its own `__bases__` +- **THEN** `cls._is_protocol` MUST be `False` + +#### Scenario: Runtime checkable application +- **WHEN** `typing.runtime_checkable` is applied to a classcore protocol + subclass via the `decorators=` metaclass argument +- **THEN** the application MUST succeed without `TypeError` +- **AND** `cls._is_runtime_protocol` MUST be `True` + +#### Scenario: Runtime structural subtyping +- **WHEN** a classcore protocol is decorated with `@runtime_checkable` +- **AND** a candidate object implements all protocol methods +- **THEN** `isinstance(candidate, protocol)` MUST return `True` + +#### Scenario: Protocol attributes collection +- **WHEN** a classcore protocol subclass is created +- **THEN** `cls.__protocol_attrs__` MUST contain the protocol's attribute names diff --git a/documentation/architecture/openspec/specs/type-safety/spec.md b/documentation/architecture/openspec/specs/type-safety/spec.md new file mode 100644 index 0000000..49cbe5e --- /dev/null +++ b/documentation/architecture/openspec/specs/type-safety/spec.md @@ -0,0 +1,108 @@ +# Type Safety + +## Purpose + +The library SHALL ensure full compatibility with static type checkers and +multiple Python implementations, with graceful degradation for +implementation-specific features. + +## Requirements + +### Requirement: Complete Type Annotations + +All public APIs MUST have complete type annotations. + +#### Scenario: Public function annotated +- **WHEN** a public function is defined +- **THEN** all parameters and return types MUST have annotations + +#### Scenario: Public class annotated +- **WHEN** a public class is defined +- **THEN** all public methods MUST have complete annotations + +### Requirement: Dataclass Transform Signaling + +Metaclasses MUST use `@dataclass_transform` to signal dataclass behavior +to type checkers. + +#### Scenario: Type checker recognizes dataclass +- **WHEN** a class uses a standard dataclass metaclass +- **THEN** type checkers MUST infer dataclass fields and methods + +### Requirement: Generic Type Variables + +Generic type variables MUST be properly constrained. + +#### Scenario: Constrained TypeVar +- **WHEN** a TypeVar is used in a public API +- **THEN** it MUST have appropriate constraints or bounds + +### Requirement: Pyright Strict Mode + +The package MUST comply with Pyright strict mode. + +#### Scenario: Pyright strict check passes +- **WHEN** Pyright runs in strict mode on the package +- **THEN** no errors MUST be reported + +### Requirement: Py.typed Marker + +The package MUST include a `py.typed` marker file. + +#### Scenario: PEP 561 compliance +- **WHEN** the package is installed +- **THEN** type checkers MUST recognize it as typed + +### Requirement: CPython Compatibility + +Core functionality MUST work on CPython 3.10+. + +#### Scenario: CPython 3.10 test pass +- **WHEN** tests run on CPython 3.10 +- **THEN** all tests MUST pass + +#### Scenario: CPython 3.13 test pass +- **WHEN** tests run on CPython 3.13 +- **THEN** all tests MUST pass + +### Requirement: PyPy Compatibility + +Core functionality MUST work on PyPy 3.10+. + +#### Scenario: PyPy test pass +- **WHEN** tests run on PyPy 3.10 +- **THEN** all tests MUST pass + +### Requirement: Platform-Specific Feature Detection + +Platform-specific optimizations (closure repair) MUST be detected and +applied conditionally. + +#### Scenario: CPython closure repair +- **WHEN** running on CPython +- **THEN** CPython-specific closure repair MUST be applied + +#### Scenario: PyPy closure repair +- **WHEN** running on PyPy +- **THEN** PyPy-specific closure repair MUST be applied + +### Requirement: No Unsafe Operations + +The package MUST NOT use `exec`, `eval`, or `ctypes`. + +#### Scenario: No exec usage +- **WHEN** the source code is analyzed +- **THEN** no `exec` calls MUST be found + +#### Scenario: No eval usage +- **WHEN** the source code is analyzed +- **THEN** no `eval` calls MUST be found + +### Requirement: Comprehensive Test Coverage + +The package MUST maintain comprehensive test coverage across all supported +implementations. + +#### Scenario: Coverage threshold +- **WHEN** coverage is measured +- **THEN** coverage MUST be at least 95% diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 119a6f7..11e213d 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -21,5 +21,387 @@ Release Notes ******************************************************************************* - .. towncrier release notes start + +classcore 1.15 (2026-10-16) +=========================== + +Enhancements +------------ + +- Export the reference attribute-name calculator publicly from + ``classcore.standard.nomina`` with a configurable package prefix: + downstream packages construct ``AttrnameCalculator( package_name = + ... )`` to generate and detect grammar-conformant internal names + under their own prefix, sharing classcore's vocabulary by + construction instead of mirroring it. The grammar containers stay + private; detection consults reserved framework vocabulary. + + +classcore 1.14 (2026-10-15) +=========================== + +Enhancements +------------ + +- Expose the class-level mutability exemption sets publicly from + ``classcore.standard.nomina``: ``abc_class_mutables`` (stdlib ABC + machinery) and ``typing_class_mutables`` (stdlib typing machinery, + including the CPython 3.15 ``__typing_is_deprecated_inherited_runtime_protocol__`` + dunder). Downstream packages share these sets by reference instead of + carrying copies which can drift, as Frigid's did on Python 3.15. The + canonical home remains ``classcore.standard.__``; the nomina aliases + are identically the same objects. + + +classcore 1.13 (2026-10-15) +=========================== + +Enhancements +------------ + +- Add attribute explanations: ``explain_attribute`` in + ``classcore.standard.explanations`` returns the decision trace for one + attribute of one target — active behaviors per evaluated level and, for + each operation, a verdict carrying its decision under precedence + semantics: permission by inapplicability, omni, names, predicate, or + regex, or prohibition. Decision payloads are typed per decision class, + explanation records are immutable with immutable nested collections, + and framework-owned and stdlib-machinery attributes are marked + internal. +- Attribute internal marking now follows metaclass-carried + contributions: the attribute namer's ``is_internal_name`` detector + marks framework names (grammar-constrained static stems and both + digest-mangled forms), the abstract-base wiring marks stdlib ABC + machinery names only where that machinery operates, and downstream + ``class_factory`` namers contribute their own detectors, so + ``explain_attribute`` reports downstream framework attributes + truthfully. ``is_internal_name`` takes the target and consults the + metaclass chain; lookalike names that fail the detector grammar stay + unmarked, and the public ``_dynadoc_fragments_`` declaration + convention is never marked. + + +Repairs +------- + +- Fix construction of function-local dataclass classes. Classes derived + from the dataclass base classes (``DataclassObject``, + ``DataclassObjectMutable``, ``DataclassProtocol``, and + ``DataclassProtocolMutable``) failed with ``TypeError`` when defined + inside a function body, because the construction marker could not be + recognized during dataclass slot reproduction. +- Permit CPython 3.15+ typing machinery dunder on immutable classes. + ``typing`` sets ``__typing_is_deprecated_inherited_runtime_protocol__`` + during class decoration and in the ``runtime_checkable`` decorator; the + class-level immutability behavior now exempts it, and the protocol + member snapshot excludes it from ``__protocol_attrs__``. + + +classcore 1.12 (2026-08-15) +=========================== + +Enhancements +------------ + +- Add abstract base class support: ``AbstractClass`` metaclass and + ``AbstractObject`` base class combine standard behaviors with + ``abc.ABCMeta`` machinery (abstract method enforcement, virtual subclass + registration), and mix with external ABC-based classes without + metaclass conflicts. Re-parent ``ProtocolClass`` under ``AbstractClass``, + unifying the standard metaclass taxonomy under ``Class``. +- Improve API documentation for class construction and initialization + signatures, including parameter descriptions and the metaclass + delegation contract. + + +Repairs +------- + +- Establish proper inheritance hierarchies for standard metaclasses. + ``Dataclass`` now inherits from ``Class``, ``DataclassMutable`` from + ``Dataclass``, ``ProtocolDataclass`` from ``ProtocolClass``, and + ``ProtocolDataclassMutable`` from ``ProtocolDataclass``. This eliminates + metaclass conflicts when inheriting from classes backed by different + metaclasses in the same family. +- Fix ``typing.runtime_checkable`` support for classcore protocol classes. + ``@runtime_checkable`` can now be applied via the ``decorators=`` + metaclass argument or directly after class creation. + ``isinstance()`` structural subtyping works correctly with + ``@runtime_checkable`` classcore protocols. +- Fix runtime protocol recognition for subclasses of classcore protocol + base classes. Previously, ``typing_extensions.Protocol.__init_subclass__`` + set ``_is_protocol = False`` on these subclasses due to an identity check + that did not recognize classcore's own protocol base classes. Subclasses + are now properly detected via structural analysis. + + +classcore 1.11 (2026-07-19) +=========================== + +Enhancements +------------ + +- Export ``ClassDecorator``, ``ClassDecorators``, ``ClassDecoratorFactory``, and ``ModuleReclassifier`` type aliases for downstream consumption. + + +classcore 1.10 (2025-09-25) +=========================== + +Enhancements +------------ + +- Expose exception mutability constants and public identifier utility function for downstream reuse. + + +Repairs +------- + +- Fix additional PyPy compatibility issues with exception and ABC cache attribute mutations. + + +classcore 1.9 (2025-09-24) +========================== + +Repairs +------- + +- Fix PyPy compatibility with super() calls in slotted dataclasses. + + +Classcore 1.8 (2025-07-23) +========================== + +Enhancements +------------ + +- Standard: Modules: Allow certain modules to be excluded from reclassification. + Also, implement cycle detection. + + +Repairs +------- + +- Standard: Modules: Reclassify modules with proper depth-first traversal. + + +Classcore 1.7 (2025-07-08) +========================== + +Enhancements +------------ + +- Standard: Add ``ignore_init_arguments`` decorator argument and + ``instances_ignore_init_arguments`` class argument to support cases, such as + inheritance from ``urllib.parse.ParseResult`` which inherits from ``tuple`` and + overrides ``__new__`` instead of ``__init__``. In such cases, ``__new__`` + processes the instance production arguments rather than ``__init__``. However, + the standard Python behavior is to present the arguments to both ``__new__`` + and ``__init__``, which is problematic since we always provide an ``__init__`` + head. + + +Classcore 1.6.1 (2025-07-01) +============================ + +Repairs +------- + +- Fix deprecation warnings from finalize_module by refactoring to use private _reclassify_module implementation. + + +Classcore 1.6 (2025-07-01) +========================== + +Enhancements +------------ + +- Add ``finalize_module`` function to combine Dynadoc docstring generation with module reclassification for immutability and concealment. + + +Notices +------- + +- Deprecate ``reclassify_modules`` function. Use ``finalize_module`` instead. + + +Classcore 1.5.3 (2025-06-30) +============================ + +Repairs +------- + +- Standard: Do not create duplicate slots for behaviors tracking. +- Standard: Ensure that behaviors tracking attribute is not part of comparisons, + hashes, or ``repr`` calculations for dataclasses. Attributes with "private" + names which resemble the CPython scheme for class-local (non-inheritable) + attributes can create confusion for the internal machinery of ``dataclasses``. + + +Classcore 1.5.2 (2025-06-29) +============================ + +Repairs +------- + +- Standard Classes: Ensure that Dynadoc decorator applies last, after any + decorators which may potentially replace classes (e.g., ``dataclass( slots = + True )``), so that the Dynadoc visitees weak set captures the correct reference + to prevent multiple decoration. + + +Classcore 1.5.1 (2025-06-26) +============================ + +Repairs +------- + +- Ensure the inheritance of replacement functions works via metaclasses and not + just decorators. + + +Classcore 1.5 (2025-06-24) +========================== + +Enhancements +------------ + +- Improve docstrings for various public type aliases. Also, drop ``Fname`` + references from public type aliases, such as ``Decorators`` so that they can be + reused in downstream packages. + + +Repairs +------- + +- Ensure that replacement implementations (``assigner_core``, ``deleter_core``, + and ``surveyor_core``) are inherited so that behaviors do not regress to + standard baseline behaviors in descendant classes. + + +Classcore 1.4.2 (2025-06-11) +============================ + +Repairs +------- + +- Ensure that custom attributes namer is passed during recursive module + reclassification. + + +Classcore 1.4.1 (2025-06-10) +============================ + +Repairs +------- + +- Enforce attributes concealment and immutability on modules. + + +Classcore 1.4 (2025-06-10) +========================== + +Enhancements +------------ + +- Fully support slotted classes. Bookkeeping attributes will now be in slots if + class is slotted. Potential performance improvement since names do not need to + be mangled for inheritance isolation. +- Publicly expose the ``TypedDict`` which tracks available metaclass arguments + for the standard classes. This allows for easier extension by downstream + packages and centralizes documentation on the metaclasses interface. + + +Repairs +------- + +- Bugfix. Ensure idempotent execution in all scenarios: CPython vs PyPy, slotted + vs non-slotted. +- Bugfix. Ensure that methods added by decoration properly respect class method + resolution order (MRO) when they are not wrapping existing methods. + + +Classcore 1.3.1 (2025-06-07) +============================ + +Repairs +------- + +- When reclassifying modules recursively, ensure that replacement class is + included in recursive invocations. + + +Classcore 1.3 (2025-06-07) +========================== + +Enhancements +------------ + +- Allow arbitrary class to be supplied to ``reclassify_modules``. +- Simplify production of class decorators. + + +Repairs +------- + +- Bugfix. Propagate error class provider from metaclass to class decorators. + + +Classcore 1.2 (2025-06-05) +========================== + +Enhancements +------------ + +- Fill out docstrings for all public classes and functions in package. +- Integrate with Dynadoc. Adds special introspection control which avoid + docstring updates on immutable classes. Adds Dynadoc ``with_docstring`` + decorator to set of decorators on standard classes. + + +Repairs +------- + +- Address Pyright complaints about metaclass arguments in ``class`` statements. + + +Classcore 1.1 (2025-05-01) +========================== + +Repairs +------- + +- Fix interaction with slotted dataclasses on Python 3.11+. +- Properly report test coverage by considering doctests too. (Not a user-facing + fix; however important to note that coverage was 100% on initial release, but + Github Actions workflow was not properly setup to capture coverage from + doctests and so it only reported 95% coverage.) + + +Classcore 1.0 (2025-04-29) +========================== + +Enhancements +------------ + +- Add support for CPython 3.10 to 3.13. +- Add support for PyPy 3.10. +- Base classes and class factory classes which provide standard behaviors + (immutability of all attributes after initialization, concealment of all + non-public attributes from ``dir``) by default. Can apply additional decorators + and can tune for selective or total mutability or visibility. Enforce on class + attributes and instance attributes. +- Class and dataclass decorators which apply a set of standard behaviors to + classes so that they produce instances which are immutable and which only + reveal public attributes by default. Decorators are tunable via arguments to + provide selective or total attributes mutability and visibility as desired. +- Class decorator which accepts a sequence of other class decorators to apply. + Reduces height of decorator stacks and improves their reusability. +- Decorators for modifying class factory classes (metaclasses) so that they can + handle inline application of decorators during production of classes. This + includes logic for the case where a decorator replaces a class rather than + modifies it. +- Module class which enforces immutability and visibility limitation on module + attributes. (Visibility restriction is to reveal only public attributes to + ``dir``.) Also, conveience function which can reclassify a module or an entire + package, recursively, to use this class. diff --git a/documentation/conf.py b/documentation/conf.py index 22fe65d..254e578 100644 --- a/documentation/conf.py +++ b/documentation/conf.py @@ -1,3 +1,6 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- mode: python ; coding: utf-8 -*- + ''' Configuration file for the Sphinx documentation builder. This file only contains a selection of the most common options. @@ -7,8 +10,6 @@ https://jareddillard.com/blog/common-ways-to-customize-sphinx-themes.html ''' -# ruff: noqa: E402,F401 - def _calculate_copyright_notice( ): from datetime import datetime as DateTime, timezone as TimeZone @@ -48,18 +49,26 @@ def _import_version( ): 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.githubpages', + 'myst_parser', 'sphinx_copybutton', 'sphinx_inline_tabs', ] templates_path = [ '_templates' ] -exclude_patterns = [ ] +exclude_patterns = [ + 'architecture/**', +] rst_prolog = f''' .. |project| replace:: {project} ''' +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} + nitpicky = True nitpick_ignore = [ # Workaround for https://bugs.python.org/issue11975 @@ -76,9 +85,9 @@ def _import_version( ): ( 'py:class', "v, remove specified key and return the corresponding value." ), # Type annotation weirdnesses. - ( 'py:class', "Doc" ), - ( 'py:class', "collections.abc.Annotated" ), - ( 'py:class', "types.Annotated" ), + ( 'py:class', "builtins.NotImplementedType" ), + ( 'py:class', "classcore.__.T" ), + ( 'py:class', "classcore.__.U" ), ( 'py:class', "typing_extensions._ProtocolMeta" ), ( 'py:class', "typing_extensions.Any" ), ] @@ -95,6 +104,12 @@ def _import_version( ): r'https://github\.com/emcd/python-classcore/.*', # Package does not exist during initial development. r'https://pypi.org/project/classcore/', + # Github aggressively rate-limits access to certain blobs. + r'https://github\.com/.*/.*/blob/.*', + # Avoid timeouts for slow sites. + r'http://www\.catb\.org/~esr/faqs/smart-questions\.html', + # GitHub intermittently returns 504 errors. + r'https://github\.com/.*', ] # -- Options for HTML output ------------------------------------------------- @@ -114,24 +129,48 @@ def _import_version( ): autodoc_default_options = { 'member-order': 'groupwise', - 'imported-members': False, - 'inherited-members': True, + 'members': True, 'show-inheritance': True, - 'undoc-members': True, + # 'special-members': '__call__', } -#autodoc_typehints = 'description' +autodoc_typehints = 'none' +autodoc_use_type_comments = False # -- Options for intersphinx extension --------------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration intersphinx_mapping = { + 'dynadoc': ( + 'https://emcd.github.io/python-dynadoc/stable/sphinx-html', None), 'python': ( 'https://docs.python.org/3', None), 'typing-extensions': ( 'https://typing-extensions.readthedocs.io/en/latest', None), + # --- BEGIN: Injected by Copier --- + # --- END: Injected by Copier --- } +# -- Options for Myst extension ---------------------------------------------- + +# https://myst-parser.readthedocs.io/en/latest/syntax/optional.html +myst_enable_extensions = [ + # 'amsmath', + # 'attrs_inline', + 'colon_fence', # ::: blocks + 'deflist', # Definition lists + # 'dollarmath', + # 'fieldlist', + # 'html_admonition', + # 'html_image', + # 'linkify', + # 'replacements', + # 'smartquotes', + # 'strikethrough', + # 'substitution', + 'tasklist', # - [ ] tasks +] + # -- Options for todo extension ---------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/extensions/todo.html#configuration diff --git a/documentation/contribution.rst b/documentation/contribution.rst index 03de3d8..da5f041 100644 --- a/documentation/contribution.rst +++ b/documentation/contribution.rst @@ -26,7 +26,7 @@ Contribution Contribution to this project is welcome! However, it must follow the `code of conduct -`_ +`_ for the project. @@ -46,31 +46,74 @@ Ways to Contribute Development =============================================================================== +Requirements and Design +------------------------------------------------------------------------------- + +* Review accepted capability requirements in ``openspec/specs/``. Manage new + capabilities, breaking or cross-cutting changes, architectural shifts, and + substantial performance or security work through OpenSpec changes in + ``openspec/changes/``. Follow the project's OpenSpec workflow instructions + and keep accepted specifications synchronized when changes are completed. + +* Document stable subsystem architecture, design rationale, and local + constraints in the nearest source-near README, normally + ``sources/**/README.md``. Update the affected README when implementation + structure or operational patterns change rather than recreating centralized + architecture or specification mirrors under ``documentation/``. + Guidance and Standards ------------------------------------------------------------------------------- * Follow the `development environment preparation and management instructions - `_ + `_ to ensure consistency with maintainer development environments and CI workflows. +* Configure Git commit signing as required for all contributions. See the + `environment setup guide + `_ + for configuration details. + * Adhere to the `development practices - `_ - and `code style `_ + `_, + `code style + `_, + and `testing guidelines + `_ to improve the probability of pull request acceptance. You may wish to use an LLM to assist with this, if the standards seem too onerous or specific. * Also consider the `nomenclature advice - `_ + `_ for consistency and to improve the probability of pull request acceptance. +* Run validation commands before submitting contributions. See the `validation + guide `_ + for available commands and workflow. (If you installed the Git pre-commit and + pre-push hooks during environment setup, then they will run the validations + for you.) + * Prepare changelog fragments according to the `releases guide - `_ + `_ as appropriate. -* Although unncessary for non-maintainer contributions, additional background +* Although unnecessary for non-maintainer contributions, additional background can be found in the `maintenance guide - `_. + `_. + +Artificial Intelligence +------------------------------------------------------------------------------- + +* Contributions, which are co-authored by large language models (LLMs), are + welcome, provided that they adhere to the project guidance and standards + above and are, otherwise, of good quality. + +* A more compact representation of the above guidance and standards, plus some + other advice for these models, can be found in + ``.auxiliary/configuration/conventions.md``. You may link to this file from a + ``AGENTS.md``, ``CLAUDE.md``, ``GEMINI.md``, ``CONVENTIONS.md``, etc... file + in the root of the project. These files are ignored by Git as we do not wish + to pollute the root of the project with them in the upstream repository. Resources ------------------------------------------------------------------------------- @@ -78,4 +121,33 @@ Resources .. toctree:: :maxdepth: 2 + Code of Conduct devapi + Environment Guide + Practices Guide + Code Style Guide + Testing Guide + Nomenclature Guide + Germanic Nomenclature Guide + Validation Guide + Release Guide + Maintenance Guide + Architecture Guide + Requirements Guide + +.. Language-specific guides stay in a hidden toctree so Sphinx includes them + in the site graph without adding renderer-specific navigation glue to the + portable common Markdown sources. +.. toctree:: + :hidden: + + Python Environment Guide + Python Architecture Guide + Python Development Guide + Rust Development Guide + TOML Configuration Practices + Latin-derived Verb Vocabulary + Python Automatic Formatting + Python Testing Guide + Python Validation Guide + Python Release Guide diff --git a/documentation/devapi.rst b/documentation/devapi.rst index cd22b59..bafacf7 100644 --- a/documentation/devapi.rst +++ b/documentation/devapi.rst @@ -28,6 +28,8 @@ Internal Development Interface Package ``classcore.__`` =============================================================================== +.. automodule:: classcore.__ + Module ``classcore.__.imports`` ------------------------------------------------------------------------------- diff --git a/documentation/examples/index.rst b/documentation/examples/index.rst index 9d1488d..3f830a3 100644 --- a/documentation/examples/index.rst +++ b/documentation/examples/index.rst @@ -28,4 +28,6 @@ Examples std-dataclasses std-protocols std-behaviors + std-explanations std-modules + std-dynadoc diff --git a/documentation/examples/std-behaviors.rst b/documentation/examples/std-behaviors.rst index b8127be..f766f22 100644 --- a/documentation/examples/std-behaviors.rst +++ b/documentation/examples/std-behaviors.rst @@ -99,7 +99,7 @@ on instances of the decorated class. >>> del point.x -Selective Visibility +Selective Mutability ------------------------------------------------------------------------------- Explicit attribute names for selective mutability: @@ -187,7 +187,7 @@ Invalid mutability verifiers will cause an error to be raised: ... Traceback (most recent call last): ... - classcore.exceptions.BehaviorExclusionInvalidity: Invalid behavior exclusion verifier: 42 + classcore.exceptions.BehaviorExclusionInvalidity: Invalid behavior exclusion verifier: 13 Inheritance ------------------------------------------------------------------------------- @@ -265,7 +265,7 @@ the produced class itself. ... x: float ... y: float ... - >>> '__annotations__' in dir( Point2d ) + >>> '__annotations__' in dir( Point2d ) or '__annotations_cache__' in dir( Point2d ) True The ``instances_visibles`` metaclass argument controls visiblity of attributes @@ -354,7 +354,7 @@ Invalid visibility verifiers will cause an error to be raised: ... Traceback (most recent call last): ... - classcore.exceptions.BehaviorExclusionInvalidity: Invalid behavior exclusion verifier: 42 + classcore.exceptions.BehaviorExclusionInvalidity: Invalid behavior exclusion verifier: 13 Inheritance diff --git a/documentation/examples/std-classes.rst b/documentation/examples/std-classes.rst index a25776d..321806e 100644 --- a/documentation/examples/std-classes.rst +++ b/documentation/examples/std-classes.rst @@ -159,3 +159,145 @@ attributes, there is a convenience class, ``ObjectMutable``. >>> point.x, point.y = 20, 21 >>> point.x, point.y (20, 21) + + +Attribute Preallocations +=============================================================================== + +You can preallocate attributes using the standard Python ``__slots__`` +mechanism. In addition to potential performance gains for attribute lookups, +this can be useful if you are making a namespace class and want to keep the +namespace dictionary free of record-keeping attributes. You cannot inherit a +standard base class, such as ``Object``, for this purpose, as it is +``__dict__``-based. However, you can create the namespace class via metaclass. + +.. doctest:: Standard.Classes + + >>> class Namespace( metaclass = ccstd.Class ): + ... __slots__ = ( '__dict__', ) + ... def __init__( self, **arguments: float ) -> None: + ... self.__dict__.update( arguments ) + ... + >>> ns = Namespace( x = 20, y = 21 ) + >>> ns.__slots__ + ('__dict__', '_classcore_instance_behaviors_') + >>> 'x' in ns.__dict__ + True + >>> '_classcore_instance_behaviors_' in ns.__dict__ + False + >>> ns.x, ns.y + (20, 21) + +The mapping form of ``__slots__`` is also supported. + +.. doctest:: Standard.Classes + + >>> class Namespace( metaclass = ccstd.Class ): + ... __slots__ = { '__dict__': 'Namespace attributes.' } + ... def __init__( self, **arguments: float ): + ... self.__dict__.update( arguments ) + ... + >>> ns = Namespace( x = 20, y = 21 ) + >>> ns.__slots__[ '__dict__' ] + 'Namespace attributes.' + + +Suppression of Initialization Arguments +=============================================================================== + +In some cases, you may inherit from classes which process their instance +construction arguments via ``__new__`` rather than ``__init__``. This is +experienced, for example, where :py:class:`tuple` and other immutable builtins +are subclassed. To prevent the construction arguments from being applied to the +``__init__`` call chain, you can set ``instances_ignore_init_arguments`` to +``True`` as a class argument. + +.. doctest:: Standard.Classes + + >>> from urllib.parse import ParseResult, urlparse + >>> class Url( ccstd.Object, ParseResult, instances_ignore_init_arguments = True ): + ... pass + ... + >>> u = Url( *urlparse( 'https://python.org' ) ) + +Or as ``ignore_init_arguments`` as ``True`` to a decorator. + +.. doctest:: Standard.Classes + + >>> @ccstd.with_standard_behaviors( ignore_init_arguments = True ) + ... class Url( ParseResult ): pass + ... + >>> u = Url( *urlparse( 'https://python.org' ) ) + + +Integrations with Custom Behaviors +=============================================================================== + +You can define dunder methods, like ``__delattr__``, ``__setattr__``, and +``__dir__``, and they will be automatically wrapped by the decorators which +setup attributes concealment and immutability enforcement on classes. + +.. doctest:: Standard.Classes + + >>> class Point2d( ccstd.ObjectMutable ): + ... def __init__( self, x: float, y: float ) -> None: + ... super( ).__init__( ) + ... self.x = x + ... self.y = y + ... def __delattr__( self, name: str ) -> None: + ... if not name.startswith( '_' ): print( name ) + ... super( ).__delattr__( name ) + ... def __setattr__( self, name: str, value ) -> None: + ... if not name.startswith( '_' ): print( f"{name} = {value!r}" ) + ... super( ).__setattr__( name, value ) + ... def __dir__( self ): + ... print( 'called dir' ) + ... return super( ).__dir__( ) + ... + >>> point = Point2d( 3, 4 ) + x = 3 + y = 4 + >>> point.x, point.y = 5, 12 + x = 5 + y = 12 + >>> del point.y + y + >>> 'x' in dir( point ) + called dir + True + +The integration points work correctly with inheritance. Furthermore, the +standard behaviors (concealment and immutability) are idempotent, which +improves their performance in class hierarchies. + +.. doctest:: Standard.Classes + + >>> class Point3d( Point2d ): + ... def __init__( self, x: float, y: float, z: float ) -> None: + ... super( ).__init__( x, y ) + ... self.z = z + ... def __delattr__( self, name: str ) -> None: + ... if name == 'z': print( 'Z!' ) + ... super( ).__delattr__( name ) + ... def __setattr__( self, name: str, value ) -> None: + ... if name == 'z': print( 'Z!' ) + ... super( ).__setattr__( name, value ) + ... def __dir__( self ): + ... print( 'called dir in 3D' ) + ... return super( ).__dir__( ) + ... + >>> point3 = Point3d( 5, 12, 17 ) + x = 5 + y = 12 + Z! + z = 17 + >>> point3.z = 60 + Z! + z = 60 + >>> del point3.z + Z! + z + >>> 'z' not in dir( point3 ) + called dir in 3D + called dir + True diff --git a/documentation/examples/std-dataclasses.rst b/documentation/examples/std-dataclasses.rst index c51e3c5..b3ac506 100644 --- a/documentation/examples/std-dataclasses.rst +++ b/documentation/examples/std-dataclasses.rst @@ -129,7 +129,7 @@ class attributes: True >>> type( Point2d ) - >>> '__annotations__' in dir( Point2d ) + >>> '__annotations__' in dir( Point2d ) or '__annotations_cache__' in dir( Point2d ) True >>> del Point2d.__annotations__ diff --git a/documentation/examples/std-dynadoc.rst b/documentation/examples/std-dynadoc.rst new file mode 100644 index 0000000..7661603 --- /dev/null +++ b/documentation/examples/std-dynadoc.rst @@ -0,0 +1,210 @@ +.. vim: set fileencoding=utf-8: +.. -*- coding: utf-8 -*- +.. +--------------------------------------------------------------------------+ + | | + | Licensed under the Apache License, Version 2.0 (the "License"); | + | you may not use this file except in compliance with the License. | + | You may obtain a copy of the License at | + | | + | http://www.apache.org/licenses/LICENSE-2.0 | + | | + | Unless required by applicable law or agreed to in writing, software | + | distributed under the License is distributed on an "AS IS" BASIS, | + | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | + | See the License for the specific language governing permissions and | + | limitations under the License. | + | | + +--------------------------------------------------------------------------+ + + +******************************************************************************* +Dynadoc Integration +******************************************************************************* + + +Introduction +=============================================================================== + +Seamless integration with Dynadoc is provided to automate documentation of +classes and their members. This integration allows you to configure Dynadoc +behavior at both the metaclass and individual class levels, ensuring that your +classes receive appropriate documentation based on their structure and +annotations. + +.. doctest:: Dynadoc.Integration + + >>> from typing import Annotated + >>> import classcore.standard as ccstd + >>> import dynadoc + + +Basic Configuration +=============================================================================== + +Dynadoc configuration can be applied to classes through the standard Classcore +metaclasses. The configuration controls how documentation is generated, +including which objects to introspect and how to render the results. + +.. doctest:: Dynadoc.Integration + + >>> class Person( ccstd.DataclassObject ): + ... ''' A person with basic information. ''' + ... name: Annotated[ str, dynadoc.Doc( "Full name of the person" ) ] + ... age: Annotated[ int, dynadoc.Doc( "Age in years" ) ] + ... email: Annotated[ str | None, dynadoc.Doc( "Email address if available" ) ] = None + ... + >>> print( Person.__doc__ ) + A person with basic information. + + :ivar name: Full name of the person + :vartype name: str + :ivar age: Age in years + :vartype age: int + :ivar email: Email address if available + :vartype email: str | None + +The documentation is automatically enhanced with parameter information derived +from the dataclass fields and their type annotations. + + +Metaclass-Level Configuration +=============================================================================== + +You can configure Dynadoc behavior for all classes created with a particular +metaclass by setting configuration attributes directly on the metaclass: + +.. doctest:: Dynadoc.Integration + + >>> # Create a custom metaclass with specific Dynadoc settings + >>> @ccstd.class_factory( + ... dynadoc_configuration = ccstd.dynadoc.produce_dynadoc_configuration( + ... preserve = False # Don't preserve existing docstrings + ... ) + ... ) + ... class CustomClass( type ): pass + ... + >>> class Calculator( metaclass = CustomClass ): + ... ''' Original calculator docstring. ''' + ... def add( + ... self, + ... x: Annotated[ float, dynadoc.Doc( "First number" ) ], + ... y: Annotated[ float, dynadoc.Doc( "Second number" ) ] + ... ) -> Annotated[ float, dynadoc.Doc( "Sum of x and y" ) ]: + ... ''' Add two numbers. ''' + ... return x + y + ... def multiply( + ... self, + ... x: Annotated[ float, dynadoc.Doc( "First number" ) ], + ... y: Annotated[ float, dynadoc.Doc( "Second number" ) ] + ... ) -> Annotated[ float, dynadoc.Doc( "Product of x and y" ) ]: + ... ''' Multiply two numbers. ''' + ... return x * y + ... + >>> print( Calculator.__doc__ ) + None + +.. code-block:: text + + >>> print( Calculator.add.__doc__ ) + Add two numbers. + + :argument self: + :argument x: First number + :type x: float + :argument y: Second number + :type y: float + :returns: Sum of x and y + :rtype: float + +.. code-block:: text + + >>> print( Calculator.multiply.__doc__ ) + Multiply two numbers. + + :argument self: + :argument x: First number + :type x: float + :argument y: Second number + :type y: float + :returns: Product of x and y + :rtype: float + +Notice how the original class docstring was completely replaced (due to +``preserve: False``) and is now ``None``, while the individual method +docstrings were enhanced with parameter and return type documentation from +their annotations. + + +Class-Level Configuration +=============================================================================== + +Individual classes can override metaclass defaults by providing their own +Dynadoc configuration as a ``class`` statement argument: + +.. doctest:: Dynadoc.Integration + + >>> class Vehicle( + ... ccstd.DataclassObject, + ... dynadoc_configuration = { + ... 'preserve': True, + ... 'introspection': ccstd.dynadoc.produce_dynadoc_introspection_control( + ... enable = False # Disable automatic introspection + ... ) + ... } + ... ): + ... ''' A vehicle with make and model information. + ... + ... This class represents various types of vehicles. + ... ''' + ... make: Annotated[ str, dynadoc.Doc( "Vehicle manufacturer" ) ] + ... model: Annotated[ str, dynadoc.Doc( "Vehicle model name" ) ] + ... year: Annotated[ int, dynadoc.Doc( "Year of manufacture" ) ] + ... + >>> print( Vehicle.__doc__ ) + A vehicle with make and model information. + + This class represents various types of vehicles. + +Since introspection was disabled, only the original docstring is preserved +without any automatic parameter documentation. + + +Documentation of Modules +=============================================================================== + +A variation of ``assign_module_docstring`` is provided, which respects +immutable classes. This function is used by this package, itself, to +automatically generate documentation for its own modules: + +.. code-block:: python + + from . import standard + + # ... other imports and definitions ... + + standard.dynadoc.assign_module_docstring( __name__, table = __.fragments ) + standard.reclassify_modules( __name__, recursive = True ) + +This automatically generates comprehensive documentation for the entire +package, including all submodules. The key benefits of this variation include: + +* **Automatic immutable class avoidance**: By default, immutable classes are not + introspected during documentation generation to prevent potential issues. + +* **Recursive package documentation**: When applied to a package, it can + recursively document all submodules. + +* **Fragment table integration**: Supports reusable documentation fragments + for consistent terminology across the package. + +You can apply this to your own modules and packages: + +.. code-block:: python + + import classcore.standard as ccstd + + # At the end of your module's __init__.py + ccstd.dynadoc.assign_module_docstring( __name__ ) + + # Optionally make the entire package immutable + ccstd.reclassify_modules( __name__, recursive = True ) diff --git a/documentation/examples/std-explanations.rst b/documentation/examples/std-explanations.rst new file mode 100644 index 0000000..ee58e79 --- /dev/null +++ b/documentation/examples/std-explanations.rst @@ -0,0 +1,155 @@ +.. vim: set fileencoding=utf-8: +.. -*- coding: utf-8 -*- +.. +--------------------------------------------------------------------------+ + | | + | Licensed under the Apache License, Version 2.0 (the "License"); | + | you may not use this file except in compliance with the License. | + | You may obtain a copy of the License at | + | | + | http://www.apache.org/licenses/LICENSE-2.0 | + | | + | Unless required by applicable law or agreed to in writing, software | + | distributed under the License is distributed on an "AS IS" BASIS, | + | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | + | See the License for the specific language governing permissions and | + | limitations under the License. | + +--------------------------------------------------------------------------+ + + +******************************************************************************* +Attribute Explanations +******************************************************************************* + +The :py:func:`classcore.standard.explanations.explain_attribute` function explains why an +attribute of a class or an instance is mutable or immutable and visible or +concealed. It returns an explanation record which carries, for each operation +— assignment, deletion, and survey — a verdict with the rule which decided +the outcome. Explanations are observational: they neither bypass nor alter the +behaviors they describe. + +.. doctest:: Standard.Explanations + + >>> import classcore.standard as ccstd + + +Explaining Immutability +=============================================================================== + +Consider a decorated class with selective mutability. Explaining an excluded +attribute name reports the decision which permitted the operation, with its +payload carried by a typed record. + +.. doctest:: Standard.Explanations + + >>> @ccstd.with_standard_behaviors( mutables = ( 'count', ) ) + ... class Counter: + ... count: int = 0 + ... + >>> explanation = ccstd.explain_attribute( Counter( ), 'count' ) + >>> assign = explanation.operations[ 'assign' ] + >>> assign.permissible + True + >>> assign.decision.name + 'count' + +Explaining a non-excluded attribute reports that the operation is +prohibited. + +.. doctest:: Standard.Explanations + + >>> explanation = ccstd.explain_attribute( Counter( ), 'total' ) + >>> assign = explanation.operations[ 'assign' ] + >>> assign.permissible + False + >>> type( assign.decision ).__name__ + 'Prohibit' + + +Explaining Concealment +=============================================================================== + +Concealment verdicts follow precedence semantics, like every operation: the +first matching visibility rule decides the survey verdict. + +.. doctest:: Standard.Explanations + + >>> import re + >>> @ccstd.with_standard_behaviors( + ... visibles = ( re.compile( 'pub.*' ), ) ) + ... class Ledger: + ... public_total = 1 + ... _secret = 2 + ... + >>> explanation = ccstd.explain_attribute( Ledger( ), 'public_total' ) + >>> survey = explanation.operations[ 'survey' ] + >>> survey.permissible + True + >>> survey.decision.pattern + 'pub.*' + + >>> explanation = ccstd.explain_attribute( Ledger( ), '_secret' ) + >>> explanation.operations[ 'survey' ].permissible + False + + +Internal Attributes +=============================================================================== + +Framework attribute names mark as internal wherever the framework's +machinery built the class. Standard library machinery names, like the +ABC caches, mark only where that machinery actually operates — a class +without ABC machinery keeps such a name unmarked. + +.. doctest:: Standard.Explanations + + >>> import hashlib + >>> digest = hashlib.sha256( b'x' ).hexdigest( ) + >>> explanation = ccstd.explain_attribute( + ... Ledger( ), f"_classcore_class_in_progress_{digest}" ) + >>> explanation.internal + True + >>> explanation = ccstd.explain_attribute( Ledger( ), '_abc_cache' ) + >>> explanation.internal + False + + >>> class Registry( ccstd.AbstractObject ): + ... pass + ... + >>> explanation = ccstd.explain_attribute( Registry( ), '_abc_cache' ) + >>> explanation.internal + True + >>> explanation = ccstd.explain_attribute( Ledger( ), 'public_total' ) + >>> explanation.internal + False + + +Record Immutability +=============================================================================== + +Explanation records are themselves immutable, including every nested +collection. + +.. doctest:: Standard.Explanations + + >>> explanation = ccstd.explain_attribute( Ledger( ), 'public_total' ) + >>> explanation.name = 'other' + Traceback (most recent call last): + ... + classcore.exceptions.AttributeImmutability: Could not assign or delete attribute 'name' on instance of class 'classcore.standard.explanations.AttributeExplanation'. + + +Summaries +=============================================================================== + +The explanation record renders a human-readable summary of the whole +decision trace, so digging through attributes and mappings is only +necessary for programmatic use. + +.. doctest:: Standard.Explanations + + >>> print( ccstd.explain_attribute( Ledger( ), 'public_total' ) ) + 'public_total' on instance of class 'builtins.Ledger' + behaviors: concealment, immutability (instance) + assign: prohibited (no permitting rule) + delete: prohibited (no permitting rule) + survey: permitted by regex 'pub.*' diff --git a/documentation/examples/std-modules.rst b/documentation/examples/std-modules.rst index 3bf483a..63b3a63 100644 --- a/documentation/examples/std-modules.rst +++ b/documentation/examples/std-modules.rst @@ -1,3 +1,4 @@ +.. vim: set fileencoding=utf-8: .. -*- coding: utf-8 -*- .. +--------------------------------------------------------------------------+ | | @@ -24,10 +25,262 @@ Standard Modules Introduction =============================================================================== -.. todo:: Contents +The ``standard.modules`` submodule provides functionality to enhance Python +modules with immutability, concealment, and automatic documentation generation. +This is particularly useful for package authors who want to prevent accidental +modification of their module's public API while providing rich documentation. +The module provides two main approaches: -Reclassification +1. **Module reclassification** - converts standard modules to have immutable + and concealed attributes +2. **Module finalization** - combines documentation generation with + reclassification in a single convenient function + + +Module Reclassification +=============================================================================== + +The ``reclassify_modules`` function converts modules to use a custom module +class that provides immutability and concealment behaviors. Here's how you +might use it in a hypothetical package: + +.. code-block:: python + + # mypackage/__init__.py + import classcore.standard as _ccstd + + # Import your submodules + from . import core + from . import utils + from . import exceptions + + # Apply module reclassification + _ccstd.reclassify_modules( __name__, recursive = True ) + +After reclassification, the modules become immutable: + +.. code-block:: python + + # This would raise AttributeImmutability exception + # mypackage.core = "something else" + + # Non-public attributes are concealed from dir() + # dir( mypackage ) # Only shows public attributes + +The ``recursive = True`` parameter ensures that all submodules within the +package hierarchy are also reclassified, providing consistent behavior +throughout your package. + + +Individual Module Reclassification +------------------------------------------------------------------------------- + +You can also reclassify individual modules without affecting the entire +package hierarchy: + +.. code-block:: python + + # mypackage/core.py + import classcore.standard as _ccstd + + def important_function(): + ''' This function should not be accidentally modified. ''' + return "Important result" + + # Reclassify only this module + _ccstd.reclassify_modules( __name__ ) + +This approach is useful when you want fine-grained control over which modules +in your package receive the enhanced behaviors. + + +Module Finalization with Documentation =============================================================================== -.. todo:: Contents +The ``finalize_module`` function provides a convenient way to combine automatic +documentation generation (via Dynadoc integration) with module reclassification. +This is the recommended approach for most packages. + +Basic Usage +------------------------------------------------------------------------------- + +.. code-block:: python + + # mypackage/__init__.py + import classcore.standard as _ccstd + + from . import core + from . import utils + from . import exceptions + + # Define documentation fragments + _fragments = { + 'version': '1.0.0', + 'author': 'Your Name', + 'description': 'A utility package for data processing' + } + + # Finalize the module with documentation and reclassification + _ccstd.finalize_module( + __name__, + dynadoc_table = _fragments, + recursive = True + ) + +The ``finalize_module`` function will: + +1. Generate comprehensive documentation for the module and its members using + Dynadoc introspection +2. Apply the documentation fragments you provide +3. Reclassify the module and its submodules for immutability and concealment + +Advanced Configuration +------------------------------------------------------------------------------- + +For complex packages, you might want to configure different parts differently: + +.. code-block:: python + + # mypackage/__init__.py + import classcore.standard as _ccstd + + # Configure main package with full documentation + _ccstd.finalize_module( + __name__, + dynadoc_table = main_fragments, + recursive = False # Handle submodules individually + ) + + # Configure submodules with different settings + _ccstd.finalize_module( + f"{__name__}.core", + dynadoc_table = core_fragments, + recursive = True + ) + + _ccstd.finalize_module( + f"{__name__}.utils", + dynadoc_table = utils_fragments, + recursive = True + ) + +This approach allows you to provide different documentation fragments and +introspection settings for different parts of your package. + + +Best Practices +=============================================================================== + +Package-Level Application +------------------------------------------------------------------------------- + +For most packages, apply ``finalize_module`` at the package level in your +``__init__.py`` file: + +.. code-block:: python + + # mypackage/__init__.py + import classcore.standard as _ccstd + + # Package metadata and documentation fragments + __version__ = '1.0.0' + _fragments = { + 'version': __version__, + 'maintainer': 'Package Team', + 'license': 'Apache 2.0' + } + + # Import public API + from .core import PublicClass, public_function + from .utils import helper_function + + # Finalize the entire package + _ccstd.finalize_module( + __name__, + dynadoc_table = _fragments, + recursive = True + ) + +This pattern ensures that: + +- Your package's public API is documented +- All modules in the package are immutable and concealed +- Documentation fragments are applied consistently +- The entire package hierarchy is protected from accidental modification + +Documentation Fragments +------------------------------------------------------------------------------- + +Use documentation fragments to provide consistent metadata across your package: + +.. code-block:: python + + # mypackage/_metadata.py + fragments = { + 'version': '1.0.0', + 'author': 'Your Name ', + 'license': 'Apache License 2.0', + 'homepage': 'https://github.com/yourname/mypackage', + 'description': 'A comprehensive data processing library', + 'examples_url': 'https://mypackage.readthedocs.io/examples', + 'api_url': 'https://mypackage.readthedocs.io/api' + } + + # mypackage/__init__.py + from ._metadata import fragments + import classcore.standard as _ccstd + + _ccstd.finalize_module( __name__, dynadoc_table = fragments, recursive = True ) + +This approach centralizes your package metadata and makes it easy to maintain +consistency across documentation. + +Error Handling +------------------------------------------------------------------------------- + +When using module finalization, be aware that the resulting modules will raise +``AttributeImmutability`` exceptions if code attempts to modify them: + +.. code-block:: python + + import classcore.exceptions + + # After finalization, this will raise an exception + try: + mypackage.core.some_function = lambda: "modified" + except classcore.exceptions.AttributeImmutability as e: + print( f"Cannot modify module: {e}" ) + +Design your package APIs to avoid dynamic modification after finalization. +If you need dynamic behavior, consider using configuration objects or factory +functions instead of direct module attribute modification. + + +Integration with Build Systems +=============================================================================== + +Module finalization integrates well with modern Python build systems. The +immutability ensures that your package's API surface is clearly defined and +cannot be accidentally modified at runtime. + +For packages that use entry points or plugin systems, apply finalization after +all dynamic setup is complete: + +.. code-block:: python + + # mypackage/__init__.py + import classcore.standard as _ccstd + + # Dynamic setup (plugin registration, etc.) + _setup_plugins() + _register_entry_points() + + # Final API definition + from .api import * + + # Lock down the package + _ccstd.finalize_module( __name__, dynadoc_table = _fragments, recursive = True ) + +This ensures that your package initialization is complete before the +immutability protections are applied. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 63327f9..02067a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,9 @@ description = 'Foundational class factories and decorators.' dynamic = [ 'version' ] license = 'Apache-2.0' readme = { 'file' = 'README.rst', 'content-type' = 'text/x-rst' } -requires-python = '>= 3.10' +requires-python = '>= 3.12' dependencies = [ + 'dynadoc ~= 1.4', 'typing-extensions', # --- BEGIN: Injected by Copier --- # --- END: Injected by Copier --- @@ -25,12 +26,11 @@ classifiers = [ # https://pypi.org/classifiers 'License :: OSI Approved :: Apache Software License', 'Programming Language :: Python :: 3 :: Only', # --- BEGIN: Injected by Copier --- - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.15', 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', # --- END: Injected by Copier --- 'Topic :: Software Development', ] @@ -53,11 +53,12 @@ year-of-origin = 2025 gha-runners = [ '/home/runner/work/python-classcore/python-classcore/', '/Users/runner/work/python-classcore/python-classcore/', + 'C:\a\python-classcore\python-classcore\', 'D:\a\python-classcore\python-classcore\', ] [tool.coverage.run] branch = true -command_line = '-m pytest' # TODO? '--fail-under' +command_line = '-m pytest' data_file = '.auxiliary/caches/pytest/coverage.sqlite3' parallel = true source = [ 'sources' ] @@ -88,16 +89,15 @@ strict-naming = false # --- BEGIN: Injected by Copier --- # --- END: Injected by Copier --- [tool.hatch.envs.default] -python = '3.10' +python = '3.12' [tool.hatch.envs.develop] description = ''' Development environment. ''' +builder = true dependencies = [ - 'Jinja2', 'coverage[toml]', - 'emcd-projects', 'furo', - 'icecream-truck', - 'packaging', + 'isort', + 'myst-parser', 'pre-commit', 'pyright', 'pytest', @@ -106,6 +106,7 @@ dependencies = [ 'sphinx-copybutton', 'sphinx-inline-tabs', 'towncrier', + 'vulture', # --- BEGIN: Injected by Copier --- # --- END: Injected by Copier --- ] @@ -119,28 +120,46 @@ PYTHONUNBUFFERED = 'TRUE' # TODO: Only for coverage/pytest. # --- END: Injected by Copier --- [tool.hatch.envs.develop.scripts] docsgen = [ - '''sphinx-build -E -b linkcheck -d .auxiliary/caches/sphinx \ - documentation .auxiliary/artifacts/sphinx-linkcheck''', - '''sphinx-build -a -d .auxiliary/caches/sphinx \ - documentation .auxiliary/artifacts/sphinx-html''', + """sphinx-build -E -b linkcheck -d .auxiliary/caches/sphinx --quiet \ + documentation .auxiliary/artifacts/sphinx-linkcheck""", + """sphinx-build -a -d .auxiliary/caches/sphinx --quiet \ + documentation .auxiliary/artifacts/sphinx-html""", +] +fragments = [ + """python .auxiliary/scripts/validate-fragments.py""", +] +module-sizes = [ + """python .auxiliary/scripts/validate-module-sizes.py""", ] linters = [ - '''ruff check --quiet sources documentation tests''', + """ruff check --quiet sources documentation tests""", + # """vibelinter check""", # --- BEGIN: Injected by Copier --- # --- END: Injected by Copier --- - '''pyright sources''', + """isort --check-only --diff sources tests""", + """pyright sources""", + 'fragments', + 'module-sizes', ] packagers = [ - '''hatch build''', + """hatch build""", # --- BEGIN: Injected by Copier --- # --- END: Injected by Copier --- ] +testers-documentation = [ + """coverage run -m sphinx.cmd.build \ + -E -b doctest -d .auxiliary/caches/sphinx --quiet \ + documentation .auxiliary/artifacts/sphinx-doctest""", +] +testers-serotine = [ """coverage run -m pytest -m slow""" ] +testers-no-reports = [ + 'coverage run', + 'testers-documentation', + 'testers-serotine', +] testers = [ 'coverage erase', - 'coverage run', - '''coverage run -m sphinx.cmd.build \ - -E -b doctest -d .auxiliary/caches/sphinx \ - documentation .auxiliary/artifacts/sphinx-doctest''', + 'testers-no-reports', 'coverage combine', 'coverage report --skip-covered', 'coverage html', @@ -157,15 +176,35 @@ description = ''' Quality assurance environment. ''' template = 'develop' [[tool.hatch.envs.qa.matrix]] python = [ - '3.10', - '3.11', '3.12', '3.13', - 'pypy3.10', + '3.14', + '3.15', ] +# [tool.hatch.metadata] +# allow-direct-references = true [tool.hatch.version] path = 'sources/classcore/__init__.py' +# https://pycqa.github.io/isort/docs/configuration/config_files.html +[tool.isort] +# profile = 'black' +src_paths = [ 'sources', 'tests' ] +case_sensitive = true +# ensure_newline_before_comments = true +# force_sort_within_sections = true +ignore_whitespace = true +include_trailing_comma = true +known_first_party = [ 'classcore' ] +lines_between_types = 1 +line_length = 79 +multi_line_output = 3 +quiet = true +# skip_gitignore = true +skip_glob = [ '*/__/imports.py', '*/__init__.py' ] +split_on_trailing_comma = true +use_parentheses = true + # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] # Note: Due to repeated painful experiences with Mypy, we use Pyright instead. @@ -179,8 +218,8 @@ strict = false # https://microsoft.github.io/pyright/#/configuration [tool.pyright] -ignore = [ 'tests' ] # Stronger hint for language server. -include = [ 'sources' ] +ignore = [ '.auxiliary', 'documentation', 'tests' ] # Ignore diagnostics. +include = [ 'sources', 'tests' ] # Consider for operations. reportConstantRedefinition = true reportInvalidTypeVarUse = true reportMatchNotExhaustive = true @@ -212,15 +251,20 @@ stubPath = 'sources/classcore/_typedecls' # Note: Cannot run doctests from Pytest, because Pytest tries to update '_' # attribute on protected modules. Instead, we use Sphinx to run doctests. minversion = '8.1' -addopts = '--capture=no --exitfirst -rfE' +addopts = """--capture=no --exitfirst --quiet -rfE -m 'not slow'""" # TODO? '--fail-under' testpaths = [ 'tests' ] python_files = [ 'test_*.py' ] python_functions = [ 'test_[0-9][0-9][0-9]_*' ] cache_dir = '.auxiliary/caches/pytest' +markers = [ + "slow: long-running tests", +] +asyncio_default_fixture_loop_scope = "function" [tool.ruff] #builtins = [ 'ic' ] cache-dir = '.auxiliary/caches/ruff' +extend-exclude = [ 'vulturefood.py' ] indent-width = 4 line-length = 79 [tool.ruff.lint] @@ -310,3 +354,16 @@ showcontent = true directory = 'repair' name = 'Repairs' showcontent = true + +[tool.vibelinter] +context = 3 +exclude_paths = [ '.auxiliary/**', '.venv/**', 'tests/**', ] + +[tool.vulture] +paths = [ '.auxiliary/configuration/vulturefood.py', 'sources' ] +min_confidence = 60 +exclude = [ + '*/imports.py', +] +ignore_decorators = [ '@__.abc.abstractmethod', '@__.typx.overload' ] +ignore_names = [ 'main' ] diff --git a/sources/classcore/README.md b/sources/classcore/README.md new file mode 100644 index 0000000..a10a5fc --- /dev/null +++ b/sources/classcore/README.md @@ -0,0 +1,472 @@ +# classcore + +Classcore is a foundational library for creating classes with customized +behaviors, particularly immutability and concealment. The library operates at +the metaclass level to provide composable, inheritable behaviors while +maintaining compatibility with standard Python features like dataclasses and +protocols. + +## Architectural Goals + +**Composability** + Enable incremental addition of class behaviors through decorators, + metaclasses, and inheritance without conflicts or unexpected interactions. + +**Transparency** + Work seamlessly with existing Python features (dataclasses, protocols, + ABCs) and tools (type checkers, IDEs). + +**Customizability** + Provide extension points at every layer for users to adapt behaviors to + specific needs through verifiers, hooks, and core function replacements. + +**Performance** + Minimize runtime overhead through strategic caching and lazy evaluation + while centralizing import costs at package initialization. + +## High-Level Architecture + +The system follows a layered architecture with clear separation of concerns: + +### Core Factory Layer + +Location: `factories.py` + +Produces metaclass components with customizable hooks: + +- `produce_class_constructor`: Creates `__new__` implementations with + preprocessor and postprocessor hooks +- `produce_class_initializer`: Creates `__init__` implementations with + completer hooks + +These factories enable the construction of metaclasses that can intercept and +augment class creation at multiple lifecycle points. + +### Decorator Layer + +Location: `decorators.py` + +Manages class decoration and replacement: + +- `apply_decorators`: Applies decorator sequences while handling class + replacements +- `decoration_by`: Composes multiple decorators with preparation hooks +- `produce_class_construction_decorator`: Wraps constructor logic into + metaclass `__new__` +- `produce_class_initialization_decorator`: Wraps initializer logic into + metaclass `__init__` + +Critical feature: Repairs class closures when decorators replace classes +(e.g., `dataclass(slots=True)`), ensuring `super()` continues to function +correctly. + +### Behavior Layer + +Location: `standard/behaviors.py` + +Implements immutability and concealment enforcement: + +- Attribute assignment control with selective mutability +- Attribute deletion control +- Attribute visibility filtering for `dir()` +- Verifier system supporting names, regexes, and predicates + +The verifier system allows fine-grained control: specify mutable attributes by +name, pattern, or custom predicate functions. + +### Standard Layer + +Location: `standard/` + +Pre-configured implementations for common use cases: + +**Base Classes:** +- `Object`: Standard class with immutability and concealment +- `DataclassObject`: Automatic dataclass conversion with standard behaviors +- `Protocol`: Protocol base class with immutability and concealment +- `AbstractObject`: Abstract class with ABC machinery and behaviors +- Variants for selective mutability (`ObjectMutable`, etc.) + +**Metaclasses:** +- `Class`: Metaclass for standard classes +- `Dataclass`: Metaclass that auto-applies dataclass decorator +- `AbstractClass`: Metaclass combining `Class` with `abc.ABCMeta` +- `ProtocolClass`: Metaclass for protocol classes +- Mutable variants of each + +**Decorators:** +- `with_standard_behaviors`: Add behaviors to existing classes +- `dataclass_with_standard_behaviors`: Dataclass creation with behaviors + +**Module Utilities:** +- `reclassify_module`: Apply immutability and concealment to module objects +- `finalize_module`: Complete module initialization with standard behaviors + +### Utility Layer + +Location: `utilities.py` + +Provides essential operations for metaclass machinery: + +- `mangle_name`: Creates unique attribute names using SHA-256 hashing to + avoid inheritance collisions +- `getattr0/setattr0/delattr0`: Access mangled attributes while respecting + `__slots__` +- `repair_class_reproduction`: Fixes closure cells when decorators replace + classes (CPython/PyPy-specific) +- `describe_object`: Generates human-readable object descriptions for error + messages + +## Data Flow + +### Class Creation Flow + +1. User defines class with standard metaclass or inherits from standard base +2. Metaclass `__new__` invoked via `produce_class_constructor` +3. Preprocessors modify bases, namespace, arguments, decorators +4. Superclass `__new__` creates initial class object +5. Progress flag prevents recursive processing +6. Postprocessors execute (e.g., adding behavior configurations) +7. Decorators applied via `apply_decorators` +8. If decorator replaces class, closures repaired +9. Metaclass `__init__` invoked via `produce_class_initializer` +10. Completers execute (e.g., freezing behaviors) +11. Progress flag cleared +12. Fully initialized class returned + +### Attribute Access Flow (Instances) + +**Assignment (`obj.attr = value`):** +1. `__setattr__` intercepted by assigner core function +2. Check for immutability behavior flag +3. If immutable, verify against mutables (names, regexes, predicates) +4. If allowed, delegate to ligation (super or core override) +5. If denied, raise `AttributeImmutability` error + +**Visibility (`dir(obj)`):** +1. `__dir__` intercepted by surveyor core function +2. Collect all attributes from object and class hierarchy +3. Check visibility verifiers (names, regexes, predicates) +4. Filter attributes based on verifiers +5. Return filtered set + +## Key Architectural Patterns + +### Factory Pattern + +Metaclass components are produced by factory functions rather than fixed +inheritance hierarchies. This enables composition of behaviors and runtime +customization of class creation logic. + +### Decorator Composition + +Multiple decorators can be applied in sequence, with automatic handling of +class replacements. The `decoration_by` function enables declarative +composition of decorator stacks. + +### Hook-Based Extension + +Every critical point in the class lifecycle offers hooks: + +- Preprocessors: Modify class construction inputs +- Postprocessors: Augment constructed classes +- Completers: Finalize initialized classes +- Preparers: Adjust decorator lists before application + +### Verifier Pattern + +Behavior exclusions (mutability, visibility) use a uniform verifier interface: + +- Literal `'*'`: Allow all +- Sequence of strings: Exact name matches +- Sequence of `re.Pattern`: Regex matches +- Sequence of callables: Predicate functions + +This provides flexible, user-customizable behavior rules. + +### Core Function Replacement + +Critical behaviors (assignment, deletion, visibility) can be replaced entirely +by providing alternative "core" functions. This enables deep customization +without subclassing. + +## Integration Points + +### Dataclasses + +Standard metaclasses automatically apply `dataclasses.dataclass` during +class construction, with configurability for `frozen`, `slots`, etc. The +decorator application system ensures proper handling of the class replacement +that occurs with `slots=True`. + +### Protocols + +Standard protocol classes combine `typing.Protocol` with immutability and +concealment. Special handling for ABC-related mutable attributes +(`_abc_cache`, `_abc_registry`, etc.) ensures protocol functionality. + +### Type Checkers + +Metaclasses use `@dataclass_transform` decorator to signal dataclass +behavior to type checkers. This enables proper type inference and checking +for classes produced by standard metaclasses. + +### Module Objects + +Module reclassification applies class-level behaviors to module objects, +enabling immutable, concealed module namespaces for API control. + +## Dependencies + +### External Dependencies + +The library depends on standard library modules only: + +- `dataclasses`: Automatic dataclass generation +- `functools`: Decorator utilities and partials +- `hashlib`: Attribute name mangling (SHA-256) +- `inspect`: Class and function introspection +- `platform`: Python implementation detection for platform-specific repairs +- `re`: Regular expression support for verifiers +- `typing`: Type hints and protocol support +- `collections.abc`: Abstract base classes + +### Internal Dependencies + +- `dynadoc`: Documentation generation with fragment interpolation +- `frigid`: Immutable data structures for internal use +- `falsifier`: Base classes for falsey objects + +## Extension Points + +Users can extend the library at multiple levels: + +**Decorator Level** + Create custom class decorators and compose with `decoration_by`. + +**Metaclass Level** + Build custom metaclasses using factory functions with custom hooks. + +**Behavior Level** + Replace core functions for assignment, deletion, visibility, or error + provision. + +**Verifier Level** + Implement custom predicate functions for mutability/visibility rules. + +**Hook Level** + Provide preprocessors, postprocessors, completers, or preparers for + lifecycle customization. + +## Filesystem Organization + +### Root Directory + +``` +python-classcore/ +├── LICENSE.txt # Project license +├── README.rst # Project overview and quick start +├── pyproject.toml # Python packaging and tool configuration +├── documentation/ # Sphinx documentation source +├── sources/ # All source code +├── tests/ # Test suites +└── .auxiliary/ # Development workspace +``` + +### Package Structure + +``` +sources/ +└── classcore/ # Main Python package + ├── __/ # Centralized import hub + │ ├── __init__.py # Re-exports core utilities + │ ├── imports.py # External library imports + │ ├── nomina.py # Naming constants + │ └── doctab.py # Documentation table fragments + ├── __init__.py # Package entry point + ├── py.typed # Type checking marker + ├── decorators.py # Class decorator utilities + ├── exceptions.py # Package exception classes + ├── factories.py # Metaclass factory functions + ├── nomina.py # Public naming types and utilities + ├── utilities.py # Class manipulation utilities + └── standard/ # Standard behaviors subpackage + ├── __.py # Inherits parent imports + ├── __init__.py # Subpackage entry point + ├── behaviors.py # Immutability and concealment logic + ├── classes.py # Standard metaclasses and base classes + ├── decorators.py # Standard class decorators + ├── dynadoc.py # Documentation configuration + ├── explanations.py # Attribute behavior explanations + ├── modules.py # Module reclassification utilities + └── nomina.py # Standard-specific naming types +``` + +### Module Responsibilities + +#### Core Package (`sources/classcore/`) + +`__/__init__.py` +: Re-exports commonly used imports from `imports.py` for convenient access + via `from . import __` in all package modules. + +`__/imports.py` +: Centralizes all external library imports. Provides abbreviated aliases for + frequently used types and modules (e.g., `cabc` for + `collections.abc`, `typx` for `typing`). + +`__/nomina.py` +: Package-specific naming constants and utilities used internally by the + `__` import hub. + +`__/doctab.py` +: Documentation table with reusable fragments for dynadoc interpolation. + +`__init__.py` +: Package entry point. Exports public API, applies standard behaviors to + package module, sets version. + +`decorators.py` +: Class decorator utilities: + + - `apply_decorators`: Apply decorator sequences with class replacement + handling + - `decoration_by`: Compose multiple decorators + - `produce_class_construction_decorator`: Create metaclass `__new__` + decorators + - `produce_class_initialization_decorator`: Create metaclass `__init__` + decorators + +`exceptions.py` +: Package exception hierarchy. All exceptions use standard behaviors + (immutability and concealment) themselves. + +`factories.py` +: Metaclass component factories: + + - `produce_class_constructor`: Generate customizable `__new__` with + hooks + - `produce_class_initializer`: Generate customizable `__init__` with + hooks + +`nomina.py` +: Public naming types, type aliases, and protocols used throughout the + package. Defines interfaces for hooks, verifiers, and core functions. + +`utilities.py` +: Class manipulation utilities: + + - `mangle_name`: Create unique attribute names + - `getattr0/setattr0/delattr0`: Mangled attribute access + - `repair_class_reproduction`: Fix closures after class replacement + - `describe_object`: Generate error message descriptions + +#### Standard Subpackage (`sources/classcore/standard/`) + +`__.py` +: Inherits all imports from parent `__` via `from ..__ import *`. + Demonstrates the cascading import pattern for subpackages. + +`__init__.py` +: Subpackage entry point. Exports all standard classes, decorators, and + utilities. Applies standard behaviors to subpackage module. + +`behaviors.py` +: Core implementation of standard behaviors: + + - `assign_attribute_if_mutable`: Conditional attribute assignment + - `delete_attribute_if_mutable`: Conditional attribute deletion + - `survey_visible_attributes`: Filter attributes for `dir()` + - Verifier processing for names, regexes, and predicates + +`explanations.py` +: Explanations of attribute behavior decisions: + + - `explain_attribute`: Decision trace for one attribute of one target + - `AttributeExplanation` and the `Verdict` records (`AssignVerdict`, + `DeleteVerdict`, `SurveyVerdict`) with derived permissibility + - The closed `Decision` hierarchy: `PermitByInapplicability`, + `PermitByOmni`, `PermitByNames`, `PermitByPredicate`, + `PermitByRegex`, `Prohibit`, with typed payloads + - Internal marking via metaclass-carried contributions: the namer + detector for framework names, the abstract-base wiring for stdlib + ABC machinery (only where that machinery operates), and downstream + factory namers' detectors for downstream names + +`classes.py` +: Standard metaclasses and base classes: + + - `Class` / `Object`: Standard class with full behaviors + - `AbstractClass` / `AbstractObject`: Abstract class with ABC machinery + (abstract method enforcement, virtual subclass registration); mixes + with external `abc.ABCMeta`-based classes + - `Dataclass` / `DataclassObject`: Auto-dataclass with behaviors + - `ProtocolClass` / `Protocol`, `ProtocolMutable`, + `DataclassProtocol`, `DataclassProtocolMutable`: Protocol classes + with behaviors + - Mutable variants for each + +`decorators.py` +: Standard class decorators: + + - `with_standard_behaviors`: Apply behaviors to existing class + - `dataclass_with_standard_behaviors`: Create dataclass with behaviors + - `class_factory`: Generic factory for creating custom metaclasses + +`dynadoc.py` +: Dynadoc configuration for standard behaviors. Provides documentation + fragments that describe behaviors automatically applied to classes. + +`modules.py` +: Module reclassification utilities: + + - `reclassify_module`: Apply behaviors to single module + - `finalize_module`: Complete module initialization (used by package) + +`nomina.py` +: Standard-specific naming types and constants. Defines behavior labels + and attribute naming functions. + +All package modules use the standard `__` import pattern as documented +in the common architecture guide. + +### Component Integration + +#### Import Hub Usage + +All modules access common dependencies through the `__` import hub: + +```python +# In any module at sources/classcore/ +from . import __ + +# Use abbreviated imports +def example( items: __.cabc.Sequence[ str ] ) -> __.typx.Any: + return __.funct.partial( some_function, items ) +``` + +This pattern: + +- Eliminates redundant import statements +- Provides consistent naming across modules +- Centralizes import changes to `__/imports.py` +- Reduces module namespace pollution + +#### Subpackage Extension + +The `standard/` subpackage demonstrates the cascading import pattern. +Its `__.py` inherits parent imports and can add specialized dependencies: + +```python +# sources/classcore/standard/__.py +from ..__ import * # Inherit all parent imports +from ..exceptions import * # Add package exceptions +``` + +Modules in `standard/` use the same import pattern but gain access to both +parent and subpackage-specific imports. + +## Sister Projects + +- **[frigid](https://github.com/emcd/python-frigid)** — Fully immutable data structures. Like classcore, but values cannot be added or changed after creation. Useful for configuration objects, constants, and thread-safe data carriers. +- **[accretive](https://github.com/emcd/python-accretive)** — Grow-only data structures. Values can be added (never modified or removed) after creation. Useful for registries, plugin systems, and sticky-state configurations. diff --git a/sources/classcore/__/__init__.py b/sources/classcore/__/__init__.py index ffb55ac..f97cc07 100644 --- a/sources/classcore/__/__init__.py +++ b/sources/classcore/__/__init__.py @@ -21,5 +21,13 @@ ''' Common constants, imports, and utilities. ''' +from .doctab import * from .imports import * from .nomina import * + + +T = typx.TypeVar( 'T', bound = type ) +U = typx.TypeVar( 'U' ) + + +dictproxy_empty: cabc.Mapping[ str, str ] = types.MappingProxyType( { } ) diff --git a/sources/classcore/__/doctab.py b/sources/classcore/__/doctab.py new file mode 100644 index 0000000..fac22bf --- /dev/null +++ b/sources/classcore/__/doctab.py @@ -0,0 +1,70 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +''' Docstring fragments. ''' + + +from . import imports as __ + + +_FragmentsTable: __.typx.TypeAlias = __.cabc.Mapping[ str, str ] +fragments: _FragmentsTable = __.types.MappingProxyType( { + + 'cfc class conceal': + ''' By default, conceals non-public class attributes. ''', + + 'cfc class protect': + ''' By default, protects class attributes. ''', + + 'cfc dynadoc': ''' Applies Dynadoc decoration to classes. ''', + + 'cfc instance conceal': + ''' Produces classes which can conceal instance attributes. ''', + + 'cfc instance protect': + ''' Produces classes which can protect instance attributes. ''', + + 'cfc produce dataclass': + ''' Produces inheritable dataclasses with keyword-only instantiation. ''', + + 'cfc produce protocol class': + ''' Produces :pep:`544` protocol classes. ''', + + 'class concealment': + ''' By default, non-public class attributes are invisible. ''', + + 'class protection': + ''' By default, class attributes are immutable. ''', + + 'class instance conceal': + ''' By default, conceals non-public instance attributes. ''', + + 'class instance protect': + ''' By default, protects instance attributes. ''', + + 'dataclass': + ''' Inheritable dataclass with keyword-only instantiation. ''', + + 'protocol class': + ''' Protocol class (:pep:`544`). Nominal and structural subtyping. ''', + + 'class dynadoc': ''' Is decorated by Dynadoc. ''', + +} ) diff --git a/sources/classcore/__/imports.py b/sources/classcore/__/imports.py index 58d3bd3..52332af 100644 --- a/sources/classcore/__/imports.py +++ b/sources/classcore/__/imports.py @@ -18,25 +18,26 @@ #============================================================================# -''' Common imports and type aliases used throughout the package. ''' +''' Common imports used throughout the package. ''' # ruff: noqa: F401 -from __future__ import annotations - +import abc import collections.abc as cabc import dataclasses as dcls import functools as funct +import hashlib import inspect import platform import re import sys import types +import dynadoc as ddoc import typing_extensions as typx # --- BEGIN: Injected by Copier --- # --- END: Injected by Copier --- - -ComparisonResult: typx.TypeAlias = bool | types.NotImplementedType +# --- BEGIN: Injected by Copier --- +# --- END: Injected by Copier --- diff --git a/sources/classcore/__/nomina.py b/sources/classcore/__/nomina.py index 1343cea..58d6112 100644 --- a/sources/classcore/__/nomina.py +++ b/sources/classcore/__/nomina.py @@ -18,14 +18,13 @@ #============================================================================# -''' Catalog of common type aliases. ''' +''' Common names and type aliases. ''' -from __future__ import annotations - from . import imports as __ +ComparisonResult: __.typx.TypeAlias = bool | __.types.NotImplementedType NominativeArguments: __.typx.TypeAlias = __.cabc.Mapping[ str, __.typx.Any ] PositionalArguments: __.typx.TypeAlias = __.cabc.Sequence[ __.typx.Any ] @@ -33,5 +32,83 @@ package_name = __name__.split( '.', maxsplit = 1 )[ 0 ] -def calculate_attrname( level: str, core: str ) -> str: - return f"_{package_name}_{level}_{core}_" +_levels = frozenset( ( 'class', 'classes', 'instance', 'instances' ) ) +_cores = frozenset( ( + 'behaviors', + 'construction_arguments', + 'dynadoc_configuration', + 'in_progress', + 'mutables_names', 'mutables_predicates', 'mutables_regexes', + 'visibles_names', 'visibles_predicates', 'visibles_regexes', + 'assigner_core', 'deleter_core', 'surveyor_core' ) ) +_hex_digits = frozenset( '0123456789abcdef' ) +_digest_length = 64 +_mangled_cores = frozenset( ( 'class_behaviors', 'class_in_progress' ) ) + + +class AttrnameCalculator: + ''' Calculates framework attribute names from level and core. + + Reference implementation of the attribute namer convention: + call instances to produce names, and consult ``is_internal_name`` + to detect names this framework generated. The package prefix is + construction-time configuration (default: this package): + downstream packages construct with their own prefix rather + than overriding either method, and it is read-only + thereafter: assignment raises, so a registered prefix cannot + drift. Detection consults the reserved + framework grammar (shape conformance, not proof of + generation); generation emits the class-level mangling stems + whose bare forms the detector does not accept. Downstream + callers writing conformance or drift tests can detect a + generated name without the private mangled-core vocabulary: + try the bare form first, then the form with 64 'a' + characters appended, which covers the two class-level + mangling stems the bare form never matches. + ''' + + def __init__( self, package_name: str = package_name ) -> None: + self._package_name = package_name + + @property + def package_name( self ) -> str: + ''' Package prefix this calculator generates and detects. ''' + return self._package_name + + def is_internal_name( self, name: str ) -> bool: + prefix = f"_{self.package_name}_" + if not name.startswith( prefix ): + return False + body = name[ len( prefix ): ] + # Static stems: _{package}_{level}_{core}_, trailing underscore. + # The class-level behaviors and in_progress stems are stored + # mangled only, so they are excluded from static acceptance. + if body.endswith( '_' ): + level, _, core = body[ :-1 ].partition( '_' ) + if level in _levels and core in _cores: + return not ( + 'class' == level + and f"{level}_{core}" in _mangled_cores ) + return False + # Mangled forms: one of the mangled cores + 64 lowercase hex, + # no trailing underscore. The mangling accessors append digests + # to these stems only. + for mangled_core in _mangled_cores: + mangled_prefix = f"{mangled_core}_" + if body.startswith( mangled_prefix ): + suffix = body[ len( mangled_prefix ): ] + return ( + _digest_length == len( suffix ) + and all( c in _hex_digits for c in suffix ) ) + return False + + def __call__( self, level: str, core: str ) -> str: + return f"_{self.package_name}_{level}_{core}_" + + +calculate_attrname = AttrnameCalculator( ) + + +def calculate_contribution_name( ) -> str: + ''' Returns metaclass attribute name holding internal-name detectors. ''' + return f"_{package_name}_internal_names_" diff --git a/sources/classcore/__init__.py b/sources/classcore/__init__.py index 25701a0..2ed2de7 100644 --- a/sources/classcore/__init__.py +++ b/sources/classcore/__init__.py @@ -18,7 +18,36 @@ #============================================================================# -''' Foundational class factories and decorators. ''' +''' Foundational class factories and decorators. + + Provides ability to create class decorators and metaclasses + with customization hooks. The metaclasses can apply class decorators + inline during the class construction and initialization process, properly + handling cases where decorators replace classes (e.g., + ``dataclasses.dataclass( slots = True )``). They also backport the repair + mechanism from newer versions of CPython to ensure that the class closure + cells are rectified on replaced classes, so that zero-argument ``super`` + calls function correctly in them. + + The ``classcore.standard`` subpackage is an example of the decorators and + customization hooks being used to provide a set of practical classes and + class decorators. Furthermore, the exception classes in the + :py:mod:`classcore.exceptions` module inherit from one of the standard + classes, making both the exception classes, themselves, and their + instances immutable and concealing their non-public attributes to reduce + API noise. I.e., this package "eats its own dog food" and provides + practical examples in so doing. + + This package is not as magical as it might seem. It does **not** rely on + any ``exec`` or ``eval`` calls and it does **not** do anything with + ``ctypes`` or similar surgical instruments. It relies completely on the + documented Python data model and the machinery that it provides. While it + is true that metaclasses can be tricky, this package is developed with a + deep, highly-evolved understanding of them. We seek simplicity over + cleverness and maintain robust tests across multiple Python + implementations and versions. The package is also very clean in terms of + static type checking (via Pyright). +''' from . import __ @@ -32,7 +61,9 @@ from .factories import * -__version__ = '1.0a0' +__version__: __.typx.Annotated[ str, __.ddoc.Visibilities.Reveal ] +__version__ = '1.15' -standard.reclassify_modules( __name__, recursive = True ) +standard.finalize_module( + __name__, dynadoc_table = __.fragments, recursive = True ) diff --git a/sources/classcore/decorators.py b/sources/classcore/decorators.py index ffd0fed..a61bf8c 100644 --- a/sources/classcore/decorators.py +++ b/sources/classcore/decorators.py @@ -21,17 +21,14 @@ ''' Utilities for the decoration of classes, including metaclasses. ''' -from __future__ import annotations - from . import __ from . import nomina as _nomina from . import utilities as _utilities -_T = __.typx.TypeVar( '_T', bound = type ) - - -def apply_decorators( cls: type, decorators: _nomina.Decorators ) -> type: +def apply_decorators( + cls: type[ __.U ], decorators: _nomina.Decorators[ __.U ] +) -> type: ''' Applies sequence of decorators to class. If decorators replace classes (e.g., ``dataclass( slots = True )``), @@ -49,9 +46,9 @@ def apply_decorators( cls: type, decorators: _nomina.Decorators ) -> type: def decoration_by( - *decorators: _nomina.Decorator, - preparers: _nomina.DecorationPreparers = ( ), -) -> _nomina.Decorator: + *decorators: _nomina.Decorator[ __.U ], + preparers: _nomina.DecorationPreparers[ __.U ] = ( ), +) -> _nomina.Decorator[ __.U ]: ''' Class decorator which applies other class decorators. Useful to apply a stack of decorators as a sequence. @@ -61,7 +58,7 @@ def decoration_by( decorators list itself, such as to inject decorators based on introspection of the class. ''' - def decorate( cls: type ) -> type: + def decorate( cls: type[ __.U ] ) -> type[ __.U ]: decorators_ = list( decorators ) for preparer in preparers: preparer( cls, decorators_ ) return apply_decorators( cls, decorators_ ) @@ -71,32 +68,62 @@ def decorate( cls: type ) -> type: def produce_class_construction_decorator( attributes_namer: _nomina.AttributesNamer, - constructor: _nomina.ClassConstructor, -) -> _nomina.Decorator: + constructor: _nomina.ClassConstructor[ __.T ], +) -> _nomina.Decorator[ __.T ]: ''' Produces metaclass decorator to control class construction. Decorator overrides ``__new__`` on metaclass. ''' - def decorate( clscls: type[ _T ] ) -> type[ _T ]: - constructor_name = attributes_namer( 'classes', 'constructor' ) - extant = getattr( clscls, constructor_name, None ) - original = getattr( clscls, '__new__' ) - if extant is original: return clscls - - def construct( - clscls_: type[ _T ], - name: str, - bases: tuple[ type, ... ], - namespace: dict[ str, __.typx.Any ], *, - decorators: _nomina.Decorators = ( ), - **arguments: __.typx.Any, - ) -> type[ object ]: - return constructor( - clscls_, original, - name, bases, namespace, arguments, decorators ) - - setattr( clscls, constructor_name, construct ) - setattr( clscls, '__new__', construct ) + def decorate( clscls: type[ __.T ] ) -> type[ __.T ]: + original = __.typx.cast( + _nomina.ClassConstructorLigation | None, + clscls.__dict__.get( '__new__' ) ) # pyright: ignore + + if original is None: + + def construct_with_super( + clscls_: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Any, + ) -> type[ object ]: + superf = __.typx.cast( + _nomina.ClassConstructorLigation, + super( clscls, clscls_ ).__new__ ) + if clscls is not clscls_: + # Parent metaclass within a hierarchy: delegate without + # duplicating preprocessing or decoration. + return superf( + clscls_, name, bases, namespace, **arguments ) + return constructor( + clscls_, superf, + name, bases, namespace, arguments, decorators ) + + setattr( clscls, '__new__', construct_with_super ) + + else: + + def construct_with_original( + clscls_: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Any, + ) -> type[ object ]: + if clscls is not clscls_: + # Parent metaclass within a hierarchy: delegate without + # duplicating preprocessing or decoration. + return original( + clscls_, name, bases, namespace, **arguments ) + return constructor( + clscls_, original, + name, bases, namespace, arguments, decorators ) + + setattr( clscls, '__new__', construct_with_original ) + return clscls return decorate @@ -105,26 +132,47 @@ def construct( def produce_class_initialization_decorator( attributes_namer: _nomina.AttributesNamer, initializer: _nomina.ClassInitializer, -) -> _nomina.Decorator: +) -> _nomina.Decorator[ __.T ]: ''' Produces metaclass decorator to control class initialization. Decorator overrides ``__init__`` on metaclass. ''' - def decorate( clscls: type[ _T ] ) -> type[ _T ]: - initializer_name = attributes_namer( 'classes', 'initializer' ) - extant = getattr( clscls, initializer_name, None ) - original = getattr( clscls, '__init__' ) - if extant is original: return clscls - - @__.funct.wraps( original ) - def initialize( - cls: type, *posargs: __.typx.Any, **nomargs: __.typx.Any - ) -> None: - ligation = __.funct.partial( original, cls ) - initializer( cls, ligation, posargs, nomargs ) - - setattr( clscls, initializer_name, initialize ) - clscls.__init__ = initialize + def decorate( clscls: type[ __.T ] ) -> type[ __.T ]: + original = __.typx.cast( + _nomina.InitializerLigation | None, + clscls.__dict__.get( '__init__' ) ) # pyright: ignore + + if original is None: + + def initialize_with_super( + cls: type, *posargs: __.typx.Any, **nomargs: __.typx.Any + ) -> None: + ligation = super( clscls, cls ).__init__ + if clscls is not type( cls ): + # Parent metaclass within a hierarchy: delegate without + # duplicating completion logic. + ligation( *posargs, **nomargs ) + return + initializer( cls, ligation, posargs, nomargs ) + + clscls.__init__ = initialize_with_super + + else: + + @__.funct.wraps( original ) + def initialize_with_original( + cls: type, *posargs: __.typx.Any, **nomargs: __.typx.Any + ) -> None: + if clscls is not type( cls ): + # Parent metaclass within a hierarchy: delegate without + # duplicating completion logic. + original( cls, *posargs, **nomargs ) + return + ligation = __.funct.partial( original, cls ) + initializer( cls, ligation, posargs, nomargs ) + + clscls.__init__ = initialize_with_original + return clscls return decorate diff --git a/sources/classcore/exceptions.py b/sources/classcore/exceptions.py index 4b3c0b2..0263686 100644 --- a/sources/classcore/exceptions.py +++ b/sources/classcore/exceptions.py @@ -21,38 +21,44 @@ ''' Exceptions from package. ''' -from __future__ import annotations - from . import __ +from . import nomina as _nomina from . import standard as _standard -class Omniexception( # pyright: ignore[reportGeneralTypeIssues] - BaseException, _standard.Object, - instances_visibles = ( '__cause__', '__context__' ), # pyright: ignore[reportCallIssue] +exception_mutables_default = ( + '__cause__', '__context__', '__suppress_context__', '__traceback__' ) +exception_visibles_default = ( + *exception_mutables_default, _nomina.is_public_identifier ) + + +class Omniexception( + _standard.Object, BaseException, + instances_mutables = exception_mutables_default, + instances_visibles = exception_visibles_default, ): ''' Base exception for package. ''' -class Omnierror( Exception, Omniexception ): +class Omnierror( Omniexception, Exception ): ''' Base error for package. ''' -class AttributeImmutability( AttributeError, Omnierror ): +class AttributeImmutability( Omnierror, AttributeError ): def __init__( self, name: str, target: str ): super( ).__init__( f"Could not assign or delete attribute {name!r} on {target}." ) -class BehaviorExclusionInvalidity( TypeError, ValueError, Omnierror ): +class BehaviorExclusionInvalidity( Omnierror, TypeError, ValueError ): def __init__( self, verifier: __.typx.Any ): super( ).__init__( f"Invalid behavior exclusion verifier: {verifier!r}" ) -class ErrorProvideFailure( RuntimeError, Omnierror ): +class ErrorProvideFailure( Omnierror, RuntimeError ): def __init__( self, name: str, reason: str ): super( ).__init__( diff --git a/sources/classcore/factories.py b/sources/classcore/factories.py index b66853c..ea4d0b5 100644 --- a/sources/classcore/factories.py +++ b/sources/classcore/factories.py @@ -21,49 +21,54 @@ ''' Factories which produce metaclass implementations. ''' -from __future__ import annotations - from . import __ from . import decorators as _decorators from . import nomina as _nomina from . import utilities as _utilities -_T = __.typx.TypeVar( '_T', bound = type ) - - def produce_class_constructor( attributes_namer: _nomina.AttributesNamer, - preprocessors: _nomina.ProduceConstructorPreprocsArgument = ( ), - postprocessors: _nomina.ProduceConstructorPostprocsArgument = ( ), -) -> _nomina.ClassConstructor: + preprocessors: _nomina.ClassConstructionPreprocessors[ __.T ] = ( ), + postprocessors: _nomina.ClassConstructionPostprocessors[ __.T ] = ( ), +) -> _nomina.ClassConstructor[ __.T ]: ''' Produces constructors for classes. ''' - def construct( # noqa: PLR0913 - clscls: type[ _T ], + def construct( # noqa: PLR0913, PLR0917 + clscls: type[ __.T ], superf: _nomina.ClassConstructorLigation, name: str, bases: tuple[ type, ... ], namespace: dict[ str, __.typx.Any ], arguments: __.NominativeArguments, - decorators: _nomina.Decorators, + decorators: _nomina.Decorators[ __.T ], ) -> type: ''' Constructs class, applying decorators and hooks. ''' bases_ = list( bases ) arguments_ = dict( arguments ) decorators_ = list( decorators ) - for prep in preprocessors: - prep( clscls, name, bases_, namespace, arguments_, decorators_ ) + for preprocessor in preprocessors: + preprocessor( + clscls, name, bases_, namespace, arguments_, decorators_ ) cls = superf( clscls, name, tuple( bases_ ), namespace, **arguments_ ) # Some decorators create new classes, which invokes this method again. # Short-circuit to prevent recursive decoration and other tangles. progress_name = attributes_namer( 'class', 'in_progress' ) - in_progress = _utilities.getattr0( cls, progress_name, False ) + progress_names = _utilities.survey_mangled_names( cls, progress_name ) + in_progress = any( + getattr( cls, key, False ) for key in progress_names ) if in_progress: return cls - setattr( cls, progress_name, True ) - for postp in postprocessors: postp( cls, decorators_ ) + progress_name_m = _utilities.mangle_name( cls, progress_name ) + setattr( cls, progress_name_m, True ) + for postprocessor in postprocessors: postprocessor( cls, decorators_ ) cls = _decorators.apply_decorators( cls, decorators_ ) - setattr( cls, progress_name, False ) + # Decoration may have replaced the class. Clear every marker + # variant, including ones inherited from the original namespace, + # so initialization completes regardless of qualname restoration. + progress_names = _utilities.survey_mangled_names( cls, progress_name ) + for progress_name_ in progress_names: + setattr( cls, progress_name_, False ) + setattr( cls, progress_name_m, False ) return cls return construct @@ -71,7 +76,7 @@ def construct( # noqa: PLR0913 def produce_class_initializer( attributes_namer: _nomina.AttributesNamer, - completers: _nomina.ProduceInitializerCompletersArgument = ( ), + completers: _nomina.ClassInitializationCompleters = ( ), ) -> _nomina.ClassInitializer: ''' Produces initializers for classes. ''' @@ -84,9 +89,11 @@ def initialize( ''' Initializes class, applying hooks. ''' superf( *posargs, **nomargs ) progress_name = attributes_namer( 'class', 'in_progress' ) - in_progress = _utilities.getattr0( cls, progress_name, False ) + progress_names = _utilities.survey_mangled_names( cls, progress_name ) + in_progress = any( + getattr( cls, key, False ) for key in progress_names ) if in_progress: return # If non-empty, then not top-level. - delattr( cls, progress_name ) + for progress_name_m in progress_names: delattr( cls, progress_name_m ) for completer in completers: completer( cls ) return initialize diff --git a/sources/classcore/nomina.py b/sources/classcore/nomina.py index d97364b..ffcd165 100644 --- a/sources/classcore/nomina.py +++ b/sources/classcore/nomina.py @@ -21,26 +21,108 @@ ''' Catalog of common type aliases. ''' -from __future__ import annotations - from . import __ -AttributesNamer: __.typx.TypeAlias = ( - __.cabc.Callable[ [ str, str ], str ] ) +AttributesNamer: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Callable[ [ str, str ], str ], + __.ddoc.Doc( + ''' Names attribute from level and core arguments. + + Level will be one of 'class', 'classes', 'instance', or + 'instances'. Core will be one of the framework cores + named in the attribute-namer grammar (mutables and + visibles rules, assigner, deleter, and surveyor cores, + and construction bookkeeping). + + Can be used by downstream packages to determine names of + bookkeeping attributes assigned by this package. + ''' ), +] + +Decorator: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Callable[ [ type[ __.U ] ], type[ __.U ] ], + __.ddoc.Doc( + ''' Class decorator. + + Takes class argument and returns class. + ''' ), +] +Decorators: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Sequence[ Decorator[ __.U ] ], + __.ddoc.Doc( + ''' Sequence of class decorators. + + Each element takes a class argument and returns a class. + ''' ), +] +DecoratorsMutable: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.MutableSequence[ Decorator[ __.U ] ], + __.ddoc.Doc( + ''' Sequence of class decorators. + + Each element takes a class argument and returns a class. + + Decorators may be inserted or removed from sequence. + ''' ), +] + +ClassDecorator: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Callable[ [ type[ __.U ] ], type[ __.U ] ], + __.ddoc.Doc( + ''' Class decorator. + + Takes class argument and returns class. + ''' ), +] +ClassDecorators: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Sequence[ ClassDecorator[ __.U ] ], + __.ddoc.Doc( + ''' Sequence of class decorators. + + Each element takes a class argument and returns a class. + ''' ), +] +ClassDecoratorFactory: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Callable[ ..., ClassDecorator[ __.U ] ], + __.ddoc.Doc( + ''' Factory that produces class decorators. + + Returns a class decorator when called. + ''' ), +] +ModuleReclassifier: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Callable[ [ __.cabc.Mapping[ str, __.typx.Any ] ], None ], + __.ddoc.Doc( + ''' Reclassifies module attributes. + + Takes a mapping of attribute names to values and applies + reclassification (e.g., immutability and concealment). + ''' ), +] -Decorator: __.typx.TypeAlias = __.cabc.Callable[ [ type ], type ] -Decorators: __.typx.TypeAlias = __.cabc.Sequence[ Decorator ] -DecoratorsMutable: __.typx.TypeAlias = __.cabc.MutableSequence[ Decorator ] +DecorationPreparer: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Callable[ [ type[ __.U ], DecoratorsMutable[ __.U ] ], None ], + __.ddoc.Doc( + ''' Class decoration preparer. -DecorationPreparer: __.typx.TypeAlias = ( - __.cabc.Callable[ [ type, DecoratorsMutable ], None ] ) -DecorationPreparers: __.typx.TypeAlias = ( - __.cabc.Sequence[ DecorationPreparer ] ) + Takes class and mutable sequence of decorators as arguments. + Can alter the sequence. + ''' ), +] +DecorationPreparers: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Sequence[ DecorationPreparer[ __.U ] ], + __.ddoc.Doc( + ''' Sequence of class decoration preparers. + + Each element takes class and mutable sequence of decorators as + arguments. And, each element can alter the sequence. + ''' ), +] ClassConstructorLigation: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ ..., type ], - __.typx.Doc( + __.ddoc.Doc( ''' Bound class constructor function. Usually from ``super( ).__new__`` or a partial function. @@ -48,7 +130,7 @@ ] InitializerLigation: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ ..., None ], - __.typx.Doc( + __.ddoc.Doc( ''' Bound initializer function. Usually from ``super( ).__init__`` or a partial function. @@ -56,7 +138,7 @@ ] AssignerLigation: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ [ str, __.typx.Any ], None ], - __.typx.Doc( + __.ddoc.Doc( ''' Bound attributes assigner function. Usually from ``super( ).__setattr__`` or a partial function. @@ -64,7 +146,7 @@ ] DeleterLigation: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ [ str ], None ], - __.typx.Doc( + __.ddoc.Doc( ''' Bound attributes deleter function. Usually from ``super( ).__delattr__`` or a partial function. @@ -72,7 +154,7 @@ ] SurveyorLigation: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ [ ], __.cabc.Iterable[ str ] ], - __.typx.Doc( + __.ddoc.Doc( ''' Bound attributes surveyor function. Usually from ``super( ).__dir__`` or a partial function. @@ -88,75 +170,102 @@ list[ type ], # bases (mutable) dict[ str, __.typx.Any ], # namespace (mutable) dict[ str, __.typx.Any ], # arguments (mutable) - DecoratorsMutable, # decorators (mutable) + DecoratorsMutable[ __.U ], # decorators (mutable) ], None ], - __.typx.Doc( + __.ddoc.Doc( ''' Processes class data before construction. For use cases, such as argument conversion. ''' ), ] +ClassConstructionPreprocessors: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Sequence[ ClassConstructionPreprocessor[ __.U ] ], + __.ddoc.Doc( ''' Processors to apply before construction of class. ''' ), +] ClassConstructionPostprocessor: __.typx.TypeAlias = __.typx.Annotated[ - __.cabc.Callable[ [ type, DecoratorsMutable ], None ], - __.typx.Doc( + __.cabc.Callable[ [ type, DecoratorsMutable[ __.U ] ], None ], + __.ddoc.Doc( ''' Processes class before decoration. For use cases, such as decorator list manipulation. ''' ), ] +ClassConstructionPostprocessors: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Sequence[ ClassConstructionPostprocessor[ __.U ] ], + __.ddoc.Doc( + ''' Processors to apply before decoration of class. ''' ), +] # TODO: ClassInitializationPreparer (arguments mutation) ClassInitializationCompleter: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ [ type ], None ], - __.typx.Doc( + __.ddoc.Doc( ''' Completes initialization of class. For use cases, such as enabling immutability once all other initialization has occurred. ''' ), ] +ClassInitializationCompleters: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Sequence[ ClassInitializationCompleter ], + __.ddoc.Doc( + ''' Processors to apply at final stage of class initialization. ''' ), +] ClassConstructor: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ [ - type, - ClassConstructorLigation, - str, - tuple[ type, ... ], - dict[ str, __.typx.Any ], - __.NominativeArguments, - Decorators, + type, # metaclass + ClassConstructorLigation, # next constructor in metaclass MRO + str, # class name + tuple[ type, ... ], # bases + dict[ str, __.typx.Any ], # namespace + __.NominativeArguments, # metaclass arguments + Decorators[ __.U ], # decorators to apply after ], type ], - __.typx.Doc( ''' Constructor to use with metaclass. ''' ), + __.ddoc.Doc( + ''' Constructs class, then applies hooks. + + Receives the metaclass ('clscls'), the next constructor in + the metaclass MRO ('superf'), the class name, bases, + namespace, metaclass arguments, and decorators to apply + after construction. Calls 'superf' to create the class, + then applies postprocessors and decorators. + + Invoked only at the most-derived metaclass of a class. + Class factory wrappers on parent metaclasses delegate + directly, so construction hooks execute exactly once per + class. + ''' ), ] ClassInitializer: __.typx.TypeAlias = __.typx.Annotated[ __.cabc.Callable[ [ - type, - InitializerLigation, - __.PositionalArguments, - __.NominativeArguments, + type, # class + InitializerLigation, # next initializer in metaclass MRO + __.PositionalArguments, # positional arguments + __.NominativeArguments, # nominative arguments ], None ], - __.typx.Doc( ''' Initializer to use with metaclass. ''' ), -] + __.ddoc.Doc( + ''' Initializes class, then applies hooks. + Receives the class ('cls'), the next initializer in the + metaclass MRO ('superf'), positional arguments, and + nominative arguments. Calls 'superf' first, then applies + completers, which finalize class behaviors (e.g., enable + immutability once all other initialization has occurred). -ProduceConstructorPreprocsArgument: __.typx.TypeAlias = __.typx.Annotated[ - __.cabc.Sequence[ ClassConstructionPreprocessor ], - __.typx.Doc( ''' Processors to apply before construction of class. ''' ), -] -ProduceConstructorPostprocsArgument: __.typx.TypeAlias = __.typx.Annotated[ - __.cabc.Sequence[ ClassConstructionPostprocessor ], - __.typx.Doc( ''' Processors to apply before decoration of class. ''' ), -] -ProduceInitializerCompletersArgument: __.typx.TypeAlias = __.typx.Annotated[ - __.cabc.Sequence[ ClassInitializationCompleter ], - __.typx.Doc( - ''' Processors to apply at final stage of class initialization. ''' ), + Invoked only at the most-derived metaclass of a class. + ''' ), ] + + +def is_public_identifier( name: str ) -> bool: + ''' Is Python identifier public? ''' + return not name.startswith( '_' ) diff --git a/sources/classcore/standard/README.md b/sources/classcore/standard/README.md new file mode 100644 index 0000000..17f245d --- /dev/null +++ b/sources/classcore/standard/README.md @@ -0,0 +1,216 @@ +# classcore.standard + +The `standard` subpackage provides pre-configured implementations of +classcore's behaviors for common use cases. It includes base classes, +metaclasses, decorators, and module utilities that combine immutability and +concealment with standard Python features like dataclasses and protocols. + +## Base Classes + +### `Object` + +Standard class with full immutability and concealment behaviors. Instances +have immutable attributes and concealed internal attributes. + +```python +from classcore.standard import Object + +class Point(Object): + x: float + y: float +``` + +### `DataclassObject` + +Automatic dataclass conversion with standard behaviors. Unlike +`dataclass(frozen=True)`, attributes can still be manipulated via +`__post_init__`. + +```python +from dataclasses import field +from math import sqrt +from classcore.standard import DataclassObject + +class Point2d(DataclassObject): + x: float + y: float + hypotenuse: float = field(init=False) + def __post_init__(self) -> None: + x, y = self.x, self.y + self.hypotenuse = sqrt(x*x + y*y) +``` + +### `Protocol` + +Protocol base class with immutability and concealment. Combines +`typing.Protocol` with standard behaviors. Immutability and concealment +are inherited by subclassed implementations. Dataclass protocol bases +(`DataclassProtocol`, `DataclassProtocolMutable`) additionally provide +automatic dataclass conversion. + +### `AbstractObject` + +Abstract base class with immutability and concealment. Combines standard +behaviors with `abc.ABCMeta` machinery: abstract method enforcement and +virtual subclass registration. Mixes with external ABC-based classes +without metaclass conflicts. + +### Mutable Variants + +- `ObjectMutable`: Object with concealment only (no immutability) +- `DataclassObjectMutable`: DataclassObject with concealment only +- `ProtocolMutable`: Protocol with concealment only +- `DataclassProtocolMutable`: DataclassProtocol with concealment only + +## Metaclasses + +### `Class` + +Metaclass for standard classes. Provides immutability and concealment to +any class that uses this metaclass. + +### `Dataclass` + +Metaclass that automatically applies `dataclasses.dataclass` during class +construction. Configurable for `frozen`, `slots`, etc. + +### `AbstractClass` + +Metaclass combining `Class` with `abc.ABCMeta`. Supports abstract method +enforcement and virtual subclass registration for classes that must mix +with external ABC-based classes. + +### `ProtocolClass` + +Metaclass for protocol classes. Combines `typing.Protocol` with standard +behaviors. Descends from `AbstractClass`, unifying the standard metaclass +taxonomy under `Class`. Dataclass protocol metaclasses +(`ProtocolDataclass`, `ProtocolDataclassMutable`) additionally apply +dataclass conversion. + +### Mutable Variants + +- `DataclassMutable`: Dataclass metaclass whose instances have mutable + attributes (concealment only) + +## Decorators + +### `with_standard_behaviors` + +Apply standard behaviors to an existing class. + +```python +from classcore.standard import with_standard_behaviors + +@with_standard_behaviors() +class ExistingClass: + x: int = 10 +``` + +### `dataclass_with_standard_behaviors` + +Create a dataclass with standard behaviors applied. + +```python +from classcore.standard import dataclass_with_standard_behaviors + +@dataclass_with_standard_behaviors() +class Point: + x: float + y: float +``` + +### `class_factory` + +Generic factory for creating custom metaclasses with standard behaviors. + +## Module Utilities + +### `reclassify_module` + +Apply immutability and concealment to a single module object. + +```python +from classcore.standard import reclassify_module + +reclassify_module(__name__) +``` + +### `finalize_module` + +Complete module initialization with standard behaviors. Used internally by +the package to apply behaviors to all subpackage modules. + +### `reclassify_modules` (deprecated) + +Apply behaviors to entire packages recursively. Deprecated in favor of +`finalize_module` for individual module control. + +## Behavior System + +### Immutability + +Attributes are immutable by default. Assignment attempts raise +`AttributeImmutability`. Deletion attempts raise +`AttributeDeletionImmutability`. + +### Concealment + +Internal attributes are concealed from `dir()` output. Only public +attributes and explicitly visible attributes are shown. + +### Selective Mutability + +Specify mutable attributes using verifiers: + +- **Names**: Exact string matches +- **Regexes**: Regular expression patterns +- **Predicates**: Custom callable functions + +```python +from classcore.standard import ObjectMutableByNames + +class Config(ObjectMutableByNames, mutable_names=('debug', 'verbose')): + debug: bool = False + verbose: bool = False + name: str = 'default' +``` + +### Verifier Interface + +Behavior exclusions use a uniform verifier interface: + +- Literal `'*'`: Allow all +- Sequence of strings: Exact name matches +- Sequence of `re.Pattern`: Regex matches +- Sequence of callables: Predicate functions + +## Integration with Dataclasses + +Standard metaclasses automatically apply `dataclasses.dataclass` during +class construction. The decorator application system ensures proper handling +of the class replacement that occurs with `slots=True`. + +Key features: +- `__post_init__` can still modify attributes (unlike `frozen=True`) +- Slot classes are properly supported +- Type checkers recognize the dataclass transform + +## Integration with Protocols + +Standard protocol classes combine `typing.Protocol` with immutability and +concealment. Special handling for ABC-related mutable attributes +(`_abc_cache`, `_abc_registry`, etc.) ensures protocol functionality. + +## Import Pattern + +The standard subpackage uses the cascading import pattern: + +```python +# sources/classcore/standard/__.py +from ..__ import * # Inherit all parent imports +from ..exceptions import * # Add package exceptions +``` + +Modules in `standard/` use the same `__` import pattern as the parent +package, gaining access to both parent and subpackage-specific imports. diff --git a/sources/classcore/standard/__.py b/sources/classcore/standard/__.py index a0b55ce..f6dd15f 100644 --- a/sources/classcore/standard/__.py +++ b/sources/classcore/standard/__.py @@ -20,15 +20,11 @@ ''' Common constants, imports, and utilities. ''' +# ruff: noqa: F403, F405 -from __future__ import annotations -from ..__ import * # noqa: F403 - - -def is_public_identifier( name: str ) -> bool: - ''' Is Python identifier public? ''' - return not name.startswith( '_' ) +from ..__ import * +from ..nomina import is_public_identifier def provide_error_class( name: str ) -> type[ Exception ]: @@ -44,3 +40,67 @@ def provide_error_class( name: str ) -> type[ Exception ]: mutables_default = ( ) visibles_default = ( is_public_identifier, ) + +# Standard library ABC machinery attribute names which the class-level +# immutability behavior permits. The C _abc implementation writes these +# cache attributes through direct dict access, so they must remain +# assignable on standard classes. Also used to mark such names as +# internal in attribute explanations. +abc_class_mutables = ( + '_abc_cache', + '_abc_negative_cache', + '_abc_negative_cache_version', + '_abc_registry', +) +# Standard library typing machinery attribute names which the +# class-level immutability behavior permits on protocol classes. +# 'typing' sets these protocol bookkeeping attributes during class +# decoration: '_is_runtime_protocol' and '__non_callable_proto_members__' +# in the 'runtime_checkable' decorator, and +# '__typing_is_deprecated_inherited_runtime_protocol__' (CPython 3.15+) +# during class decoration and in 'runtime_checkable'. Without this +# exemption, decorating a runtime protocol which inherits runtime +# checkability fails. Also used to mark such names as internal in +# attribute explanations. +typing_class_mutables = ( + '_is_runtime_protocol', + '__non_callable_proto_members__', + '__typing_is_deprecated_inherited_runtime_protocol__', # Python 3.15+ +) + + +def is_same_detector( + detector: cabc.Callable[ [ str ], bool ], + other: cabc.Callable[ [ str ], bool ], /, +) -> bool: + ''' Returns whether two detector references are the same detector. + + Bound methods are the same detector only when both the + underlying function and the bound instance are identical, so + distinct namer instances with distinct configured behavior keep + separate contributions. Plain callables compare by identity. + ''' + if detector is other: return True + function = getattr( detector, '__func__', None ) + other_function = getattr( other, '__func__', None ) + if function is None or other_function is None: return False + return ( + function is other_function + and getattr( detector, '__self__', None ) + is getattr( other, '__self__', None ) ) + + +def augment_internal_names( + cls: type, /, detector: cabc.Callable[ [ str ], bool ] +) -> None: + ''' Adds an internal-name detector to existing contributions. + + Private wiring point for machinery mixins: the abstract-base + metaclass adds the stdlib machinery set after factory + decoration, without expanding public factory arguments. + ''' + detectors = getattr( cls, calculate_contribution_name( ), ( ) ) + if any( is_same_detector( detector, d ) for d in detectors ): return + setattr( + cls, calculate_contribution_name( ), + ( *detectors, detector ) ) diff --git a/sources/classcore/standard/__init__.py b/sources/classcore/standard/__init__.py index 64f32fb..488c616 100644 --- a/sources/classcore/standard/__init__.py +++ b/sources/classcore/standard/__init__.py @@ -18,11 +18,36 @@ #============================================================================# -''' Decorators and class factories providing concealment and immutability. ''' +''' Decorators and class factories providing concealment and immutability. + Concealment restricts the visibility of attributes on classes and their + instances. By default, only public attributes (ones which do not start with + ``_``) are revealed for :py:func:`dir` calls. This behavior can be + overriden by supplying visibility verifiers as a decorator factory + argument or metaclass argument. These can be a sequence of attribute + names, regular expression :py:class:`re.Pattern` objects which match + attribute names, or predicate functions which match attribute names. Or, + total visibility (per the Python default) can be achieved by supplying + ``visibles = '*'`` instead of a sequence of verifiers. -from __future__ import annotations + Immutability prevents assignment (including reassignment) or deletion of + attrubtes on classes and their instances after they have been completely + initialized. In addition to any standard Python class, this can be applied + to dataclasses, allowing them to use ``__post_init__`` to set attributes, + which ``dataclasses.dataclass( frozen = True )`` prevents. The + immutability behavior can be overridden by supplying mutability verifiers + as a decorator factory argument or metaclass argument. These behave + similarly to the visibility verifiers described above. + + Hooks to modify the concealment and immutability behaviors are also + available. +''' + + +from . import dynadoc +from . import nomina from .classes import * from .decorators import * +from .explanations import * from .modules import * diff --git a/sources/classcore/standard/behaviors.py b/sources/classcore/standard/behaviors.py index a136b8c..525105f 100644 --- a/sources/classcore/standard/behaviors.py +++ b/sources/classcore/standard/behaviors.py @@ -23,15 +23,29 @@ # Maybe enum for mutability and visibility. -from __future__ import annotations - from .. import utilities as _utilities from . import __ from . import nomina as _nomina -concealment_label = 'concealment' -immutability_label = 'immutability' +def access_core_function( # noqa: PLR0913 + cls: type, /, *, + attributes_namer: _nomina.AttributesNamer, + arguments: __.cabc.Mapping[ str, __.typx.Any ], + level: str, + name: str, + default: __.cabc.Callable[ ..., __.typx.Any ], +) -> __.cabc.Callable[ ..., __.typx.Any ]: + ''' Accesses core behavior function. + + First checks for override argument, then checks for heritable + attribute. Finally, falls back to provided default. + ''' + argument_name = f"{level}_{name}_core" + attribute_name = attributes_namer( level, f"{name}_core" ) + return ( + arguments.get( argument_name ) + or getattr( cls, attribute_name, default ) ) def assign_attribute_if_mutable( # noqa: PLR0913 @@ -43,34 +57,18 @@ def assign_attribute_if_mutable( # noqa: PLR0913 name: str, value: __.typx.Any, ) -> None: - leveli = 'instance' if level == 'instances' else level - behaviors_name = attributes_namer( leveli, 'behaviors' ) - behaviors = _utilities.getattr0( obj, behaviors_name, frozenset( ) ) - if immutability_label not in behaviors: + ''' Assigns attribute if it is mutable, else raises error. ''' + behaviors = survey_active_behaviors( + obj, attributes_namer = attributes_namer, level = level ) + if _nomina.immutability_label not in behaviors: ligation( name, value ) return - names_name = attributes_namer( level, 'mutables_names' ) - names: _nomina.BehaviorExclusionNamesOmni = ( - getattr( obj, names_name, frozenset( ) ) ) - if names == '*' or name in names: + rule = provide_permitter_if_applicable( + obj, attributes_namer = attributes_namer, + level = level, basename = 'mutables', name = name ) + if rule is not None: ligation( name, value ) return - predicates_name = attributes_namer( level, 'mutables_predicates' ) - predicates: _nomina.BehaviorExclusionPredicates = ( - getattr( obj, predicates_name, ( ) ) ) - for predicate in predicates: - if predicate( name ): - # TODO? Cache predicate hit. - ligation( name, value ) - return - regexes_name = attributes_namer( level, 'mutables_regexes' ) - regexes: _nomina.BehaviorExclusionRegexes = ( - getattr( obj, regexes_name, ( ) ) ) - for regex in regexes: - if regex.fullmatch( name ): - # TODO? Cache regex hit. - ligation( name, value ) - return target = _utilities.describe_object( obj ) raise error_class_provider( 'AttributeImmutability' )( name, target ) @@ -83,36 +81,148 @@ def delete_attribute_if_mutable( # noqa: PLR0913 level: str, name: str, ) -> None: - leveli = 'instance' if level == 'instances' else level - behaviors_name = attributes_namer( leveli, 'behaviors' ) - behaviors = _utilities.getattr0( obj, behaviors_name, frozenset( ) ) - if immutability_label not in behaviors: + ''' Deletes attribute if it is mutable, else raises error. ''' + behaviors = survey_active_behaviors( + obj, attributes_namer = attributes_namer, level = level ) + if _nomina.immutability_label not in behaviors: ligation( name ) return - names_name = attributes_namer( level, 'mutables_names' ) - names: _nomina.BehaviorExclusionNamesOmni = ( - getattr( obj, names_name, frozenset( ) ) ) - if names == '*' or name in names: + rule = provide_permitter_if_applicable( + obj, attributes_namer = attributes_namer, + level = level, basename = 'mutables', name = name ) + if rule is not None: ligation( name ) return - predicates_name = attributes_namer( level, 'mutables_predicates' ) + target = _utilities.describe_object( obj ) + raise error_class_provider( 'AttributeImmutability' )( name, target ) + + +def determine_relevant_permitters( + objct: object, /, *, + attributes_namer: _nomina.AttributesNamer, + level: str, + basename: str, + name: str, +) -> tuple[ tuple[ str, __.typx.Any ], ... ]: + ''' Returns exclusion rules matching name, in evaluation order. + + A names match short-circuits, matching the survey core's + historical control flow: predicates and regexes are not evaluated + for a name found in the exclusion names. Otherwise a name may + match any number of predicates and any number of regexes. The + wildcard is not considered here; callers handle its + short-circuit separately. + + Rules pair a kind ('names', 'predicate', 'regex') with the raw + matched verifier (name, predicate, or regex). Matching never + renders diagnostic text: enforcement and survey consult kind + and presence only, so exotic callables cannot disrupt them. + Render verifiers with 'render_verifier_text' at the record + layer when producing explanation records. + ''' + names_name = attributes_namer( level, f"{basename}_names" ) + names: _nomina.BehaviorExclusionNamesOmni = ( + getattr( objct, names_name, frozenset( ) ) ) + if name in names: return ( ( 'names', name ), ) + predicates_name = attributes_namer( level, f"{basename}_predicates" ) predicates: _nomina.BehaviorExclusionPredicates = ( - getattr( obj, predicates_name, ( ) ) ) + getattr( objct, predicates_name, ( ) ) ) + matched: list[ tuple[ str, __.typx.Any ] ] = [ + ( 'predicate', predicate ) + for predicate in predicates if predicate( name ) ] + regexes_name = attributes_namer( level, f"{basename}_regexes" ) + regexes: _nomina.BehaviorExclusionRegexes = ( + getattr( objct, regexes_name, ( ) ) ) + matched.extend( + ( 'regex', regex ) + for regex in regexes if regex.fullmatch( name ) ) + return tuple( matched ) + + +def provide_permitter_if_applicable( + objct: object, /, *, + attributes_namer: _nomina.AttributesNamer, + level: str, + basename: str, + name: str, +) -> __.typx.Optional[ tuple[ str, __.typx.Any ] ]: + ''' Returns first exclusion rule permitting operation, else None. + + Rules apply by precedence: omni or names membership, then + the first matching predicate, then the first matching regex. + Each rule pairs a kind ('omni', 'names', 'predicate', 'regex') + with the raw matched verifier. Matching never renders + diagnostic text: enforcement consults kind and presence only, + so exotic callables cannot disrupt it. Render the verifier + with 'render_verifier_text' at the record layer when producing + explanation records. + ''' + names_name = attributes_namer( level, f"{basename}_names" ) + names: _nomina.BehaviorExclusionNamesOmni = ( + getattr( objct, names_name, frozenset( ) ) ) + if names == '*': return ( 'omni', '*' ) + if name in names: return ( 'names', name ) + predicates_name = attributes_namer( level, f"{basename}_predicates" ) + predicates: _nomina.BehaviorExclusionPredicates = ( + getattr( objct, predicates_name, ( ) ) ) for predicate in predicates: if predicate( name ): - # TODO? Cache predicate hit. - ligation( name ) - return - regexes_name = attributes_namer( level, 'mutables_regexes' ) + return ( 'predicate', predicate ) + regexes_name = attributes_namer( level, f"{basename}_regexes" ) regexes: _nomina.BehaviorExclusionRegexes = ( - getattr( obj, regexes_name, ( ) ) ) + getattr( objct, regexes_name, ( ) ) ) for regex in regexes: if regex.fullmatch( name ): - # TODO? Cache regex hit. - ligation( name ) - return - target = _utilities.describe_object( obj ) - raise error_class_provider( 'AttributeImmutability' )( name, target ) + return ( 'regex', regex ) + return None + + +def render_verifier_text( verifier: __.typx.Any, / ) -> str: + ''' Returns stable detail text for an exclusion verifier. + + Regexes render as pattern text; predicates render as + fully-qualified name, with an anonymous fallback when no + qualified name is available. Strings do not reach this + function; they are classified as names before rendering. A + non-string 'pattern' attribute is not trusted as text; such + verifiers fall through to qualified-name rendering, where a + non-string qualified name falls back to '' while + a non-string module name preserves the qualified name. + Hostile metadata (raising descriptors, custom + '__getattribute__') yields '' rather than + raising, so diagnostic rendering never disrupts its callers. + The broad 'except Exception' is deliberate: rendering is + diagnostics, and diagnostics must never break enforcement + or survey paths. + + Note: the inspect utilities were considered and rejected here; + they resolve modules to objects and provide no qualified-name + rendering with an anonymous fallback, while this getattr chain + stays defensive for exotic callables (e.g., partials). + ''' + try: + text = _render_verifier_text( verifier ) + except Exception: + text = '' + return text + + +def survey_active_behaviors( + objct: object, /, *, + attributes_namer: _nomina.AttributesNamer, + level: str, +) -> __.cabc.Set[ str ]: + ''' Returns active behaviors set for object at level. + + This is the shared entry point of the behavior decision cascade: + every core first determines whether the governing behavior is + active before consulting exclusion configuration. + ''' + leveli = 'instance' if level == 'instances' else level + behaviors_name = attributes_namer( leveli, 'behaviors' ) + behaviors: __.cabc.Set[ str ] = ( + _utilities.getattr0( objct, behaviors_name, frozenset( ) ) ) + return behaviors def survey_visible_attributes( @@ -121,39 +231,51 @@ def survey_visible_attributes( attributes_namer: _nomina.AttributesNamer, level: str, ) -> __.cabc.Iterable[ str ]: + ''' Returns sequence of visible attributes. ''' names_base = ligation( ) - leveli = 'instance' if level == 'instances' else level - behaviors_name = attributes_namer( leveli, 'behaviors' ) - behaviors = _utilities.getattr0( obj, behaviors_name, frozenset( ) ) - if concealment_label not in behaviors: return names_base + behaviors = survey_active_behaviors( + obj, attributes_namer = attributes_namer, level = level ) + if _nomina.concealment_label not in behaviors: return names_base names_name = attributes_namer( level, 'visibles_names' ) names: _nomina.BehaviorExclusionNamesOmni = ( getattr( obj, names_name, frozenset( ) ) ) if names == '*': return names_base # pragma: no branch - regexes_name = attributes_namer( level, 'visibles_regexes' ) - regexes: _nomina.BehaviorExclusionRegexes = ( - getattr( obj, regexes_name, ( ) ) ) - predicates_name = attributes_namer( level, 'visibles_predicates' ) - predicates: _nomina.BehaviorExclusionPredicates = ( - getattr( obj, predicates_name, ( ) ) ) names_: list[ str ] = [ ] for name in names_base: - if name in names: - names_.append( name ) - continue - for predicate in predicates: - if predicate( name ): - # TODO? Cache predicate hit. - names_.append( name ) - continue - for regex in regexes: - if regex.fullmatch( name ): - # TODO? Cache regex hit. - names_.append( name ) - continue + matched = determine_relevant_permitters( + obj, attributes_namer = attributes_namer, + level = level, basename = 'visibles', name = name ) + # Current behavior: one append per matched rule, so a name + # matching several rules is yielded several times. Preserved + # deliberately; normalization is a separate repair. + names_.extend( name for rule in matched ) return names_ +def augment_class_attributes_allocations( + attributes_namer: _nomina.AttributesNamer, + namespace: dict[ str, __.typx.Any ], +) -> None: + ''' Adds necessary slots for record-keeping attributes. ''' + behaviors_name = attributes_namer( 'instance', 'behaviors' ) + slots: __.typx.Union[ + __.cabc.Mapping[ str, __.typx.Any ], + __.cabc.Sequence[ str ], + None + ] = namespace.get( '__slots__' ) + if slots and behaviors_name in slots: return + if isinstance( slots, __.cabc.Mapping ): + slots_ = dict( slots ) + slots_[ behaviors_name ] = 'Active behaviors.' + slots_ = __.types.MappingProxyType( slots_ ) + elif isinstance( slots, __.cabc.Sequence ): + slots_ = list( slots ) + slots_.append( behaviors_name ) + slots_ = tuple( slots_ ) + else: return # pragma: no cover + namespace[ '__slots__' ] = slots_ + + def classify_behavior_exclusion_verifiers( verifiers: _nomina.BehaviorExclusionVerifiers ) -> tuple[ @@ -161,6 +283,7 @@ def classify_behavior_exclusion_verifiers( _nomina.BehaviorExclusionRegexes, _nomina.BehaviorExclusionPredicates, ]: + ''' Threshes sequence of behavior exclusion verifiers into bins. ''' names: set[ str ] = set( ) regexes: list[ __.re.Pattern[ str ] ] = [ ] predicates: list[ __.cabc.Callable[ ..., bool ] ] = [ ] @@ -179,39 +302,59 @@ def classify_behavior_exclusion_verifiers( def produce_class_construction_preprocessor( attributes_namer: _nomina.AttributesNamer -) -> _nomina.ClassConstructionPreprocessor: +) -> _nomina.ClassConstructionPreprocessor[ __.U ]: + ''' Produces construction processor which handles metaclass arguments. ''' - def preprocess( # noqa: PLR0913 + def preprocess( # noqa: PLR0913, PLR0917 clscls: type, name: str, bases: list[ type ], namespace: dict[ str, __.typx.Any ], arguments: dict[ str, __.typx.Any ], - decorators: _nomina.DecoratorsMutable, + decorators: _nomina.DecoratorsMutable[ __.U ], ) -> None: record_class_construction_arguments( attributes_namer, namespace, arguments ) + if '__slots__' in namespace: + augment_class_attributes_allocations( attributes_namer, namespace ) return preprocess def produce_class_construction_postprocessor( - attributes_namer: _nomina.AttributesNamer -) -> _nomina.ClassConstructionPostprocessor: + attributes_namer: _nomina.AttributesNamer, + error_class_provider: _nomina.ErrorClassProvider, +) -> _nomina.ClassConstructionPostprocessor[ __.U ]: + ''' Produces construction processor which determines class decorators. ''' arguments_name = attributes_namer( 'class', 'construction_arguments' ) + cores_default = dict( + assigner = assign_attribute_if_mutable, + deleter = delete_attribute_if_mutable, + surveyor = survey_visible_attributes ) def postprocess( - cls: type, decorators: _nomina.DecoratorsMutable + cls: type, decorators: _nomina.DecoratorsMutable[ __.U ] ) -> None: arguments = getattr( cls, arguments_name, { } ) + clscls = type( cls ) dcls_spec = getattr( cls, '__dataclass_transform__', None ) if not dcls_spec: # either base class or metaclass may be marked - clscls = type( cls ) dcls_spec = getattr( clscls, '__dataclass_transform__', None ) + cores = { } + for core_name in ( 'assigner', 'deleter', 'surveyor' ): + core_function = access_core_function( + cls, + attributes_namer = attributes_namer, + arguments = arguments, + level = 'instances', name = core_name, + default = cores_default[ core_name ] ) + cores[ core_name ] = core_function instances_mutables = arguments.get( 'instances_mutables', __.mutables_default ) instances_visibles = arguments.get( 'instances_visibles', __.visibles_default ) + instances_ignore_init_arguments = arguments.get( + 'instances_ignore_init_arguments', False ) if dcls_spec and dcls_spec.get( 'kw_only_default', False ): from .decorators import dataclass_with_standard_behaviors decorator_factory = dataclass_with_standard_behaviors @@ -220,9 +363,27 @@ def postprocess( else: from .decorators import with_standard_behaviors decorator_factory = with_standard_behaviors - decorator = decorator_factory( - mutables = instances_mutables, visibles = instances_visibles ) + decorator: _nomina.Decorator[ __.U ] = decorator_factory( + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + assigner_core = __.typx.cast( + _nomina.AssignerCore, cores[ 'assigner' ] ), + deleter_core = __.typx.cast( + _nomina.DeleterCore, cores[ 'deleter' ] ), + surveyor_core = __.typx.cast( + _nomina.SurveyorCore, cores[ 'surveyor' ] ), + ignore_init_arguments = instances_ignore_init_arguments, + mutables = instances_mutables, + visibles = instances_visibles ) decorators.append( decorator ) + # Dynadoc tracks objects in weakset. + # Must decorate after any potential class replacements. + dynadoc_cfg = arguments.get( 'dynadoc_configuration', { } ) + if not dynadoc_cfg: # either metaclass argument or attribute + dynadoc_cfg_name = ( + attributes_namer( 'classes', 'dynadoc_configuration' ) ) + dynadoc_cfg = getattr( clscls, dynadoc_cfg_name, { } ) + decorators.append( __.ddoc.with_docstring( **dynadoc_cfg ) ) return postprocess @@ -230,6 +391,7 @@ def postprocess( def produce_class_initialization_completer( attributes_namer: _nomina.AttributesNamer ) -> _nomina.ClassInitializationCompleter: + ''' Produces initialization completer which finalizes class behaviors. ''' arguments_name = attributes_namer( 'class', 'construction_arguments' ) def complete( cls: type ) -> None: @@ -243,15 +405,16 @@ def complete( cls: type ) -> None: record_behavior( cls, attributes_namer = attributes_namer, level = 'class', basename = 'mutables', - label = immutability_label, behaviors = behaviors, + label = _nomina.immutability_label, behaviors = behaviors, verifiers = mutables ) record_behavior( cls, attributes_namer = attributes_namer, level = 'class', basename = 'visibles', - label = concealment_label, behaviors = behaviors, + label = _nomina.concealment_label, behaviors = behaviors, verifiers = visibles ) # Set behaviors attribute last since it enables enforcement. - setattr( cls, attributes_namer( 'class', 'behaviors' ), behaviors ) + behaviors_name = attributes_namer( 'class', 'behaviors' ) + _utilities.setattr0( cls, behaviors_name, frozenset( behaviors ) ) return complete @@ -265,6 +428,7 @@ def record_behavior( # noqa: PLR0913 behaviors: set[ str ], verifiers: _nomina.BehaviorExclusionVerifiersOmni, ) -> None: + ''' Records details of particular class behavior, such as immutability. ''' names_name = attributes_namer( level, f"{basename}_names" ) if verifiers == '*': setattr( cls, names_name, '*' ) @@ -297,6 +461,7 @@ def record_class_construction_arguments( namespace: dict[ str, __.typx.Any ], arguments: dict[ str, __.typx.Any ], ) -> None: + ''' Captures metaclass arguments as class attribute for later use. ''' arguments_name = attributes_namer( 'class', 'construction_arguments' ) arguments_ = namespace.get( arguments_name, { } ) # Decorators, which replace classes, will cause construction of the @@ -306,6 +471,11 @@ def record_class_construction_arguments( arguments_ = { } for name in ( 'class_mutables', 'class_visibles', + 'dynadoc_configuration', + 'instances_assigner_core', + 'instances_deleter_core', + 'instances_surveyor_core', + 'instances_ignore_init_arguments', 'instances_mutables', 'instances_visibles', ): if name not in arguments: continue @@ -323,3 +493,21 @@ def _deduplicate_merge_sequences( if addend in augends_: continue result.append( addend ) return tuple( result ) + + +def _render_verifier_text( verifier: __.typx.Any, / ) -> str: + ''' Returns detail text for an exclusion verifier, possibly raising. + + Total rendering lives in 'render_verifier_text', which maps + hostile-metadata failures to the documented fallback. This + partial function holds the getattr chain so the wrapper has + no returns inside its guarded block. + ''' + pattern = getattr( verifier, 'pattern', None ) + if isinstance( pattern, str ): return pattern + qualname = getattr( verifier, '__qualname__', None ) + if not isinstance( qualname, str ): return '' + module = getattr( verifier, '__module__', None ) + if not isinstance( module, str ) or '' in qualname: + return qualname + return f"{module}.{qualname}" diff --git a/sources/classcore/standard/classes.py b/sources/classcore/standard/classes.py index 76f46b0..ae3498f 100644 --- a/sources/classcore/standard/classes.py +++ b/sources/classcore/standard/classes.py @@ -19,74 +19,432 @@ ''' Standard classes and class factories. ''' -# TODO? ClassMutable and ProtocolClassMutable -# Need inheritance of omnimutability and omnivisibility. -from __future__ import annotations - from . import __ from . import decorators as _decorators +from . import dynadoc as _dynadoc +from . import nomina as _nomina + + +_protocol_cls_set = frozenset( { __.typx.Protocol } ) +# Attributes never considered declared protocol members: typing internals, +# class-creation machinery, and framework attributes declared in classcore +# base class bodies. +_protocol_attr_excluded = frozenset( { + '_is_protocol', '_is_runtime_protocol', + '__protocol_attrs__', '__subclasshook__', + '__non_callable_proto_members__', + '_dynadoc_fragments_', + # Injected during class creation by ABC, Generic, and typing machinery. + # '__init__' is '_no_init' on protocol bases; as a structural member it + # is vacuous since every candidate object has an '__init__'. + # '__typing_is_deprecated_inherited_runtime_protocol__' is set by + # CPython 3.15+ typing on protocol classes which inherit runtime + # checkability; as a structural member it is vacuous since it records + # machinery provenance rather than a callable member. + '__abstractmethods__', '__parameters__', '__init__', '__orig_bases__', + '__typing_is_deprecated_inherited_runtime_protocol__', # Python 3.15+ +} ) +_protocol_attr_prefixes = ( '_classcore_', '_abc_' ) +# Standard Python attributes present in every pre-decoration namespace. +_python_class_defaults = frozenset( { + '__module__', '__qualname__', '__doc__', '__dict__', '__weakref__', + '__slots__', '__annotations__', + '__firstlineno__', '__static_attributes__', # Python 3.13+ +} ) + + +def _snapshot_declared_protocol_attrs( cls: type ) -> None: + ''' Records protocol members declared before framework decoration. + + Called in metaclass '__new__' immediately after class creation, + before behavior decorators and dataclass machinery inject their + attributes. The pre-decoration namespace contains exactly the + members declared by the author, so a user-declared dunder (e.g., + '__repr__') is preserved while framework-generated ones are absent. + ''' + # Dataclass slot machinery reproduces classes, copying '__dict__'. + # Retain the original snapshot; the reproduction's namespace already + # contains decorated attributes. + if '_classcore_protocol_declared_' in cls.__dict__: return + declared = ( + set( cls.__dict__ ) + | set( __.inspect.get_annotations( cls ) ) ) + declared -= _python_class_defaults + declared = { + attr for attr in declared + if attr not in _protocol_attr_excluded + and not any( attr.startswith( p ) for p in _protocol_attr_prefixes ) + } + setattr( cls, '_classcore_protocol_declared_', frozenset( declared ) ) + +_dynadoc_configuration = ( + _dynadoc.produce_dynadoc_configuration( table = __.fragments ) ) +_class_factory = __.funct.partial( + _decorators.class_factory, dynadoc_configuration = _dynadoc_configuration ) + + +class ClassFactoryExtraArguments( __.typx.TypedDict, total = False ): + ''' Extra arguments accepted by standard metaclasses. ''' + + class_mutables: _nomina.BehaviorExclusionVerifiersOmni + class_visibles: _nomina.BehaviorExclusionVerifiersOmni + dynadoc_configuration: _nomina.DynadocConfiguration + instances_assigner_core: _nomina.AssignerCore + instances_deleter_core: _nomina.DeleterCore + instances_surveyor_core: _nomina.SurveyorCore + instances_ignore_init_arguments: bool + instances_mutables: _nomina.BehaviorExclusionVerifiersOmni + instances_visibles: _nomina.BehaviorExclusionVerifiersOmni + + +@_class_factory( ) +class Class( type ): + ''' Metaclass for standard classes. ''' + + _dynadoc_fragments_ = ( + 'cfc class conceal', 'cfc class protect', 'cfc dynadoc', + 'cfc instance conceal', 'cfc instance protect' ) + + def __new__( # Typechecker stub. + clscls: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Unpack[ ClassFactoryExtraArguments ], + ) -> __.T: + return super( ).__new__( clscls, name, bases, namespace ) + + +@_class_factory( ) +class AbstractClass( Class, __.abc.ABCMeta ): + ''' Metaclass for abstract classes with standard behaviors. + + Combines the standard behaviors of `Class` with the machinery of + `abc.ABCMeta` (abstract method enforcement, virtual subclass + registration) via a diamond hierarchy. `Class` itself remains + backed by plain `type`, so ABC machinery applies only where this + metaclass is used. + + Contributes the stdlib ABC and typing machinery names as internal + for the classes it builds, mirroring the class_mutables exemption + scoping. + ''' + + _dynadoc_fragments_ = ( + 'cfc class conceal', 'cfc class protect', 'cfc dynadoc', + 'cfc instance conceal', 'cfc instance protect' ) + + def __new__( # Typechecker stub. + clscls: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Unpack[ ClassFactoryExtraArguments ], + ) -> __.T: + return super( ).__new__( clscls, name, bases, namespace ) + + +@_class_factory( ) +@__.typx.dataclass_transform( frozen_default = True, kw_only_default = True ) +class Dataclass( Class ): + ''' Metaclass for standard dataclasses. ''' + + _dynadoc_fragments_ = ( + 'cfc produce dataclass', + 'cfc class conceal', 'cfc class protect', 'cfc dynadoc', + 'cfc instance conceal', 'cfc instance protect' ) + + def __new__( # Typechecker stub. + clscls: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Unpack[ ClassFactoryExtraArguments ], + ) -> __.T: + return super( ).__new__( clscls, name, bases, namespace ) + + +@_class_factory( ) +@__.typx.dataclass_transform( kw_only_default = True ) +class DataclassMutable( Dataclass ): + ''' Metaclass for dataclasses with mutable instance attributes. ''' + + _dynadoc_fragments_ = ( + 'cfc produce dataclass', + 'cfc class conceal', 'cfc class protect', 'cfc dynadoc', + 'cfc instance conceal' ) + + def __new__( # Typechecker stub. + clscls: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Unpack[ ClassFactoryExtraArguments ], + ) -> __.T: + return super( ).__new__( clscls, name, bases, namespace ) + + +@_class_factory( ) +class ProtocolClass( AbstractClass, type( __.typx.Protocol ) ): + ''' Metaclass for standard protocol classes. ''' + + _dynadoc_fragments_ = ( + 'cfc produce protocol class', + 'cfc class conceal', 'cfc class protect', 'cfc dynadoc', + 'cfc instance conceal', 'cfc instance protect' ) + + def __new__( # Typechecker stub. + clscls: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Unpack[ ClassFactoryExtraArguments ], + ) -> __.T: + cls = super( ).__new__( clscls, name, bases, namespace ) + # typing_extensions.Protocol.__init_subclass__ uses identity comparison + # (b is Protocol) to set _is_protocol on subclasses. Classcore protocol + # base classes are not typing.Protocol, so subclasses incorrectly get + # _is_protocol = False. Detect protocol base classes structurally and + # fix _is_protocol before decorators are applied + # (e.g., runtime_checkable). + if not cls.__dict__.get( '_is_protocol', False ): + for base in cls.__bases__: + if ( base.__dict__.get( '_is_protocol', False ) + and bool( _protocol_cls_set & set( base.__bases__ ) ) ): + setattr( cls, '_is_protocol', True ) + break + if cls.__dict__.get( '_is_protocol', False ): + _snapshot_declared_protocol_attrs( cls ) + return cls + + def __init__( + cls, + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], + **kwargs: __.typx.Any, + ) -> None: + super( ).__init__( name, bases, namespace, **kwargs ) + # Replace _ProtocolMeta's scan of the decorated namespace with the + # pre-decoration snapshot, so isinstance() only checks declared + # protocol members. Snapshot persists because metaclass __init__ can + # run more than once when dataclass machinery reproduces classes. + if getattr( cls, '_is_protocol', False ): + declared: frozenset[ str ] = cls.__dict__.get( + '_classcore_protocol_declared_', frozenset( ) ) + inherited: set[ str ] = set( ) + for base in cls.__mro__[ 1: ]: + if not isinstance( base, ProtocolClass ): continue + inherited.update( base.__dict__.get( + '__protocol_attrs__', ( ) ) ) + attrs = { + attr for attr in declared | inherited + if attr not in _protocol_attr_excluded + and not any( + attr.startswith( p ) for p in _protocol_attr_prefixes ) + } + setattr( cls, '__protocol_attrs__', attrs ) + + +@_class_factory( ) +@__.typx.dataclass_transform( frozen_default = True, kw_only_default = True ) +class ProtocolDataclass( ProtocolClass ): + ''' Metaclass for standard protocol dataclasses. ''' + + _dynadoc_fragments_ = ( + 'cfc produce protocol class', 'cfc produce dataclass', + 'cfc class conceal', 'cfc class protect', 'cfc dynadoc', + 'cfc instance conceal', 'cfc instance protect' ) + + def __new__( # Typechecker stub. + clscls: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Unpack[ ClassFactoryExtraArguments ], + ) -> __.T: + return super( ).__new__( clscls, name, bases, namespace ) + + +@_class_factory( ) +@__.typx.dataclass_transform( kw_only_default = True ) +class ProtocolDataclassMutable( ProtocolDataclass ): + ''' Metaclass for protocol dataclasses with mutable instance attributes. + ''' + _dynadoc_fragments_ = ( + 'cfc produce protocol class', 'cfc produce dataclass', + 'cfc class conceal', 'cfc class protect', 'cfc dynadoc', + 'cfc instance conceal' ) -@_decorators.decoration_by( *_decorators.class_factory_decorators ) -class Class( type ): pass - + def __new__( # Typechecker stub. + clscls: type[ __.T ], + name: str, + bases: tuple[ type, ... ], + namespace: dict[ str, __.typx.Any ], *, + decorators: _nomina.Decorators[ __.T ] = ( ), + **arguments: __.typx.Unpack[ ClassFactoryExtraArguments ], + ) -> __.T: + return super( ).__new__( clscls, name, bases, namespace ) -@_decorators.decoration_by( *_decorators.class_factory_decorators ) -@__.typx.dataclass_transform( frozen_default = True, kw_only_default = True ) -class Dataclass( type ): pass +class Object( metaclass = Class ): + ''' Standard base class. ''' -@_decorators.decoration_by( *_decorators.class_factory_decorators ) -@__.typx.dataclass_transform( kw_only_default = True ) -class DataclassMutable( type ): pass + _dynadoc_fragments_ = ( + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal', 'class instance protect' ) -@_decorators.decoration_by( *_decorators.class_factory_decorators ) -class ProtocolClass( type( __.typx.Protocol ) ): pass +class ObjectMutable( metaclass = Class, instances_mutables = '*' ): + ''' Base class with mutable instance attributes. ''' + _dynadoc_fragments_ = ( + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal' ) -@_decorators.decoration_by( *_decorators.class_factory_decorators ) -@__.typx.dataclass_transform( frozen_default = True, kw_only_default = True ) -class ProtocolDataclass( type( __.typx.Protocol ) ): pass +class AbstractObject( + metaclass = AbstractClass, + class_mutables = __.abc_class_mutables, +): + ''' Base class for abstract classes with standard behaviors. -@_decorators.decoration_by( *_decorators.class_factory_decorators ) -@__.typx.dataclass_transform( kw_only_default = True ) -class ProtocolDataclassMutable( type( __.typx.Protocol ) ): pass + Supports abstract method enforcement and virtual subclass + registration, and mixes with external classes whose metaclass is + `abc.ABCMeta`. + ''' + _dynadoc_fragments_ = ( + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal', 'class instance protect' ) -class Object( metaclass = Class ): pass +class DataclassObject( metaclass = Dataclass ): + ''' Standard base dataclass. ''' -class ObjectMutable( # pyright: ignore[reportGeneralTypeIssues] - metaclass = Class, - instances_mutables = '*', # pyright: ignore[reportCallIssue] -): pass + _dynadoc_fragments_ = ( + 'dataclass', + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal', 'class instance protect' ) -class DataclassObject( metaclass = Dataclass ): pass +class DataclassObjectMutable( metaclass = DataclassMutable ): + ''' Base dataclass with mutable instance attributes. ''' + _dynadoc_fragments_ = ( + 'dataclass', + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal' ) -class DataclassObjectMutable( metaclass = DataclassMutable ): pass +class Protocol( + __.typx.Protocol, + metaclass = ProtocolClass, + class_mutables = ( + *__.abc_class_mutables, *__.typing_class_mutables ), +): + ''' Standard base protocol class. ''' -class Protocol( __.typx.Protocol, metaclass = ProtocolClass ): pass + _dynadoc_fragments_ = ( + 'protocol class', + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal', 'class instance protect' ) -class ProtocolMutable( # pyright: ignore[reportGeneralTypeIssues] +class ProtocolMutable( __.typx.Protocol, metaclass = ProtocolClass, - instances_mutables = '*', # pyright: ignore[reportCallIssue] -): pass + class_mutables = ( + *__.abc_class_mutables, *__.typing_class_mutables ), + instances_mutables = '*', +): + ''' Base protocol class with mutable instance attributes. ''' + + _dynadoc_fragments_ = ( + 'protocol class', + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal' ) class DataclassProtocol( - __.typx.Protocol, metaclass = ProtocolDataclass, -): pass + __.typx.Protocol, + metaclass = ProtocolDataclass, + class_mutables = ( + *__.abc_class_mutables, *__.typing_class_mutables ), +): + ''' Standard base protocol dataclass. ''' + + _dynadoc_fragments_ = ( + 'dataclass', 'protocol class', + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal', 'class instance protect' ) class DataclassProtocolMutable( - __.typx.Protocol, metaclass = ProtocolDataclassMutable, -): pass + __.typx.Protocol, + metaclass = ProtocolDataclassMutable, + class_mutables = ( + *__.abc_class_mutables, *__.typing_class_mutables ), +): + ''' Base protocol dataclass with mutable instance attributes. ''' + + _dynadoc_fragments_ = ( + 'dataclass', 'protocol class', + 'class concealment', 'class protection', 'class dynadoc', + 'class instance conceal' ) + + +# =========================================================================== # +# Type checker canaries. Private declarations, excluded from the public API +# and the test suite. They exist so that Pyright (and other type checkers) +# evaluates the structures declared above; regressions in checker support +# surface as diagnostics on these declarations during routine linting. +# =========================================================================== # + + +class _CanaryAbstractObject( AbstractObject ): + ''' Canary for abstract base classes. ''' + + def provide( self ) -> int: + return 42 + + +class _CanaryProtocolUse( Protocol ): + ''' Canary for protocol definitions. ''' + + value: int + + def greet( self ) -> str: ... + + +class _CanaryProtocolImpl( _CanaryProtocolUse ): + ''' Canary for concrete protocol implementations. ''' + + def __init__( self ) -> None: + self.value = 7 + + def greet( self ) -> str: + return 'canary' + + +_canary_abstract: int = _CanaryAbstractObject( ).provide( ) +_canary_protocol_iface: _CanaryProtocolUse = _CanaryProtocolImpl( ) +_canary_greeting: str = _canary_protocol_iface.greet( ) + + +def _is_stdlib_machinery_name( name: str ) -> bool: + return ( + name in __.abc_class_mutables + or name in __.typing_class_mutables ) + + +__.augment_internal_names( AbstractClass, _is_stdlib_machinery_name ) diff --git a/sources/classcore/standard/decorators.py b/sources/classcore/standard/decorators.py index 9b6ed62..56c4055 100644 --- a/sources/classcore/standard/decorators.py +++ b/sources/classcore/standard/decorators.py @@ -21,10 +21,6 @@ ''' Standard decorators. ''' # TODO? Add attribute value transformer as standard decorator argument. -# ruff: noqa: F401 - - -from __future__ import annotations from .. import factories as _factories from .. import utilities as _utilities @@ -35,316 +31,661 @@ ) from . import __ from . import behaviors as _behaviors +from . import dynadoc as _dynadoc from . import nomina as _nomina -_U = __.typx.TypeVar( '_U' ) +_dataclass_core = __.dcls.dataclass( kw_only = True, slots = True ) +_dynadoc_configuration = _dynadoc.produce_dynadoc_configuration( ) -_dataclass_core = __.dcls.dataclass( kw_only = True, slots = True ) +def prepare_dataclass_for_instances( + cls: type, + decorators: _nomina.DecoratorsMutable[ __.U ], /, *, + attributes_namer: _nomina.AttributesNamer, +) -> None: + ''' Annotates dataclass in support of instantiation machinery. ''' + annotations = __.inspect.get_annotations( cls ) + behaviors_name = attributes_namer( 'instance', 'behaviors' ) + # NOTE: The behaviors field is a dataclass field, so its name must + # stay unmangled; mangling would desynchronize the field from + # the slot or class attribute installed by the dataclass + # machinery, which addresses it by its plain name. + behaviors_name_ = behaviors_name + annotations[ behaviors_name_ ] = set[ str ] + setattr( cls, '__annotations__', annotations ) # in case of absence + setattr( cls, behaviors_name_, __.dcls.field( + compare = False, hash = False, init = False, repr = False ) ) + + +def apply_cfc_core_functions( + clscls: type[ __.T ], /, + attributes_namer: _nomina.AttributesNamer, + assigner_core: __.typx.Optional[ _nomina.AssignerCore ] = None, + deleter_core: __.typx.Optional[ _nomina.DeleterCore ] = None, + surveyor_core: __.typx.Optional[ _nomina.SurveyorCore ] = None, +) -> None: + ''' Stores core functions on metaclass. ''' + cores = dict( + classes_assigner_core = assigner_core, + classes_deleter_core = deleter_core, + classes_surveyor_core = surveyor_core ) + cores_default = dict( + assigner = _behaviors.assign_attribute_if_mutable, + deleter = _behaviors.delete_attribute_if_mutable, + surveyor = _behaviors.survey_visible_attributes ) + for core_name in ( 'assigner', 'deleter', 'surveyor' ): + core_function = _behaviors.access_core_function( + clscls, + attributes_namer = attributes_namer, + arguments = cores, + level = 'classes', name = core_name, + default = cores_default[ core_name ] ) + core_aname = attributes_namer( 'classes', f"{core_name}_core" ) + setattr( clscls, core_aname, core_function ) + + +def apply_internal_names( + cls: type, /, + attributes_namer: _nomina.AttributesNamer, +) -> None: + ''' Records the namer's internal-name contribution, if any. + + Works for metaclasses (contributions inherit to every class the + metaclass builds) and for decorator-path classes (contributions + ride the class itself, since plain decorated classes have no + custom metaclass). Parent wiring may already contribute the + same detector (e.g., a diamond re-running the factory with the + same namer); keep one of each by identity. + ''' + detector: __.typx.Optional[ _nomina.InternalNameDetector ] = ( + getattr( attributes_namer, 'is_internal_name', None ) ) + if detector is None: return + detectors: _nomina.InternalNameDetectors = getattr( + cls, __.calculate_contribution_name( ), ( ) ) + if any( __.is_same_detector( detector, d ) for d in detectors ): return + setattr( + cls, __.calculate_contribution_name( ), + ( *detectors, detector ) ) + + +def apply_cfc_dynadoc_configuration( + clscls: type[ __.T ], /, + attributes_namer: _nomina.AttributesNamer, + configuration: _nomina.DynadocConfiguration, +) -> None: + ''' Stores Dynadoc configuration on metaclass. ''' + configuration_name = attributes_namer( 'classes', 'dynadoc_configuration' ) + setattr( clscls, configuration_name, configuration ) -def _produce_class_factory_core( +def apply_cfc_constructor( + clscls: type[ __.T ], /, attributes_namer: _nomina.AttributesNamer, error_class_provider: _nomina.ErrorClassProvider, -) -> tuple[ _nomina.ClassConstructor, _nomina.ClassInitializer ]: +) -> None: + ''' Injects '__new__' method into metaclass. ''' preprocessors = ( _behaviors.produce_class_construction_preprocessor( attributes_namer = attributes_namer ), ) postprocessors = ( _behaviors.produce_class_construction_postprocessor( - attributes_namer = attributes_namer ), ) - completers = ( - _behaviors.produce_class_initialization_completer( - attributes_namer = attributes_namer ), ) - constructor = ( + attributes_namer = attributes_namer, + error_class_provider = error_class_provider ), ) + constructor: _nomina.ClassConstructor[ __.T ] = ( _factories.produce_class_constructor( attributes_namer = attributes_namer, preprocessors = preprocessors, postprocessors = postprocessors ) ) + decorator = produce_class_construction_decorator( + attributes_namer = attributes_namer, constructor = constructor ) + decorator( clscls ) + + +def apply_cfc_initializer( + clscls: type[ __.T ], /, attributes_namer: _nomina.AttributesNamer +) -> None: + ''' Injects '__init__' method into metaclass. ''' + completers = ( + _behaviors.produce_class_initialization_completer( + attributes_namer = attributes_namer ), ) initializer = ( _factories.produce_class_initializer( attributes_namer = attributes_namer, completers = completers ) ) - return constructor, initializer + decorator = produce_class_initialization_decorator( + attributes_namer = attributes_namer, initializer = initializer ) + decorator( clscls ) -def prepare_dataclass_for_instances( - cls: type, - decorators: _nomina.DecoratorsMutable, /, *, +def apply_cfc_attributes_assigner( + clscls: type[ __.T ], /, attributes_namer: _nomina.AttributesNamer, + error_class_provider: _nomina.ErrorClassProvider, + implementation_core: __.typx.Optional[ _nomina.AssignerCore ], ) -> None: - ''' Annotates dataclass in support of instantiation machinery. ''' - annotations = __.inspect.get_annotations( cls ) - behaviors_name = attributes_namer( 'instance', 'behaviors' ) - annotations[ behaviors_name ] = set[ str ] - setattr( cls, '__annotations__', annotations ) # in case of absence - setattr( cls, behaviors_name, __.dcls.field( init = False ) ) + ''' Injects '__setattr__' method into metaclass. ''' + decorator = produce_attributes_assignment_decorator( + level = 'classes', + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + implementation_core = implementation_core ) + decorator( clscls ) -def produce_class_factory_decorators( +def apply_cfc_attributes_deleter( + clscls: type[ __.T ], /, + attributes_namer: _nomina.AttributesNamer, + error_class_provider: _nomina.ErrorClassProvider, + implementation_core: __.typx.Optional[ _nomina.DeleterCore ], +) -> None: + ''' Injects '__delattr__' method into metaclass. ''' + decorator = produce_attributes_deletion_decorator( + level = 'classes', + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + implementation_core = implementation_core ) + decorator( clscls ) + + +def apply_cfc_attributes_surveyor( + clscls: type[ __.T ], + attributes_namer: _nomina.AttributesNamer, + implementation_core: __.typx.Optional[ _nomina.SurveyorCore ], +) -> None: + ''' Injects '__dir__' method into metaclass. ''' + decorator = produce_attributes_surveillance_decorator( + level = 'classes', + attributes_namer = attributes_namer, + implementation_core = implementation_core ) + decorator( clscls ) + + +def class_factory( # noqa: PLR0913, PLR0917 attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, error_class_provider: _nomina.ErrorClassProvider = __.provide_error_class, - assigner_core: _nomina.AssignerCore = ( - _behaviors.assign_attribute_if_mutable ), - deleter_core: _nomina.DeleterCore = ( - _behaviors.delete_attribute_if_mutable ), - surveyor_core: _nomina.SurveyorCore = ( - _behaviors.survey_visible_attributes ), -) -> _nomina.Decorators: - decorators: list[ _nomina.Decorator ] = [ ] - constructor, initializer = ( - _produce_class_factory_core( + assigner_core: __.typx.Optional[ _nomina.AssignerCore ] = None, + deleter_core: __.typx.Optional[ _nomina.DeleterCore ] = None, + surveyor_core: __.typx.Optional[ _nomina.SurveyorCore ] = None, + dynadoc_configuration: __.cabc.Mapping[ str, __.typx.Any ] = ( + _dynadoc_configuration ), +) -> _nomina.Decorator[ __.T ]: + ''' Produces decorator to apply standard behaviors to metaclass. ''' + def decorate( clscls: type[ __.T ] ) -> type[ __.T ]: + apply_cfc_core_functions( + clscls, attributes_namer = attributes_namer, - error_class_provider = error_class_provider ) ) - decorators.append( - produce_class_construction_decorator( + assigner_core = assigner_core, + deleter_core = deleter_core, + surveyor_core = surveyor_core ) + apply_internal_names( + clscls, attributes_namer = attributes_namer ) + apply_cfc_dynadoc_configuration( + clscls, attributes_namer = attributes_namer, - constructor = constructor ) ) - decorators.append( - produce_class_initialization_decorator( + configuration = dynadoc_configuration ) + apply_cfc_constructor( + clscls, attributes_namer = attributes_namer, - initializer = initializer ) ) - decorators.append( - produce_attributes_assignment_decorator( - level = 'class', + error_class_provider = error_class_provider ) + apply_cfc_initializer( clscls, attributes_namer = attributes_namer ) + apply_cfc_attributes_assigner( + clscls, attributes_namer = attributes_namer, error_class_provider = error_class_provider, - implementation_core = assigner_core ) ) - decorators.append( - produce_attributes_deletion_decorator( - level = 'class', + implementation_core = assigner_core ) + apply_cfc_attributes_deleter( + clscls, attributes_namer = attributes_namer, error_class_provider = error_class_provider, - implementation_core = deleter_core ) ) - decorators.append( - produce_attributes_surveillance_decorator( - level = 'class', + implementation_core = deleter_core ) + apply_cfc_attributes_surveyor( + clscls, attributes_namer = attributes_namer, - implementation_core = surveyor_core ) ) - return decorators + implementation_core = surveyor_core ) + return clscls + return decorate -def produce_instances_initialization_decorator( + +def produce_instances_inception_decorator( # noqa: PLR0913, PLR0917 attributes_namer: _nomina.AttributesNamer, + assigner_core: __.typx.Optional[ _nomina.AssignerCore ], + deleter_core: __.typx.Optional[ _nomina.DeleterCore ], + surveyor_core: __.typx.Optional[ _nomina.SurveyorCore ], + ignore_init_arguments: bool, mutables: _nomina.BehaviorExclusionVerifiersOmni, visibles: _nomina.BehaviorExclusionVerifiersOmni, -) -> _nomina.Decorator: - def decorate( cls: type[ _U ] ) -> type[ _U ]: - initializer_name = attributes_namer( 'instances', 'initializer' ) - extant = getattr( cls, initializer_name, None ) - original = getattr( cls, '__init__' ) - if extant is original: return cls +) -> _nomina.Decorator[ __.U ]: + ''' Produces decorator to inject '__new__' or '__init__' method. + + Also handles common bookkeeping tasks. + ''' + cores = dict( + instances_assigner_core = assigner_core, + instances_deleter_core = deleter_core, + instances_surveyor_core = surveyor_core ) + cores_default = dict( + assigner = _behaviors.assign_attribute_if_mutable, + deleter = _behaviors.delete_attribute_if_mutable, + surveyor = _behaviors.survey_visible_attributes ) + + def decorate( cls: type[ __.U ] ) -> type[ __.U ]: + apply_internal_names( + cls, attributes_namer = attributes_namer ) + for core_name in ( 'assigner', 'deleter', 'surveyor' ): + core_function = _behaviors.access_core_function( + cls, + attributes_namer = attributes_namer, + arguments = cores, + level = 'instances', name = core_name, + default = cores_default[ core_name ] ) + core_aname = attributes_namer( 'instances', f"{core_name}_core" ) + setattr( cls, core_aname, core_function ) behaviors: set[ str ] = set( ) - behaviors_name = attributes_namer( 'instance', 'behaviors' ) _behaviors.record_behavior( cls, attributes_namer = attributes_namer, level = 'instances', basename = 'mutables', - label = _behaviors.immutability_label, behaviors = behaviors, + label = _nomina.immutability_label, behaviors = behaviors, verifiers = mutables ) _behaviors.record_behavior( cls, attributes_namer = attributes_namer, level = 'instances', basename = 'visibles', - label = _behaviors.concealment_label, behaviors = behaviors, + label = _nomina.concealment_label, behaviors = behaviors, verifiers = visibles ) - - @__.funct.wraps( original ) - def initialize( - self: object, *posargs: __.typx.Any, **nomargs: __.typx.Any - ) -> None: - original( self, *posargs, **nomargs ) - behaviors_ = _utilities.getattr0( self, behaviors_name, set( ) ) - if not behaviors_: setattr( self, behaviors_name, behaviors_ ) - behaviors_.update( behaviors ) - - setattr( cls, initializer_name, initialize ) - cls.__init__ = initialize - return cls + decorator = produce_instances_initialization_decorator( + attributes_namer = attributes_namer, + behaviors = behaviors, + ignore_init_arguments = ignore_init_arguments ) + return decorator( cls ) return decorate -def produce_attributes_assignment_decorator( - level: str, +# def produce_instances_construction_decorator( +# attributes_namer: _nomina.AttributesNamer, +# behaviors: __.cabc.MutableSet[ str ], +# ) -> _nomina.Decorator[ __.U ]: +# ''' Produces decorator to inject '__new__' method. ''' +# def decorate( cls_: type[ __.U ] ) -> type[ __.U ]: +# behaviors_name = attributes_namer( 'instance', 'behaviors' ) +# original = cls_.__dict__.get( '__new__' ) +# +# if original is None: +# +# def initialize_with_super( +# cls: type[ __.U ], +# *posargs: __.typx.Any, +# **nomargs: __.typx.Any, +# ) -> __.U: +# self = super( cls_, cls ).__new__( cls, *posargs, **nomargs ) +# _activate_instance_behaviors( +# cls_, self, behaviors_name, behaviors ) +# return self +# +# cls_.__new__ = initialize_with_super +# +# else: +# +# @__.funct.wraps( original ) +# def initialize_with_original( +# cls: type[ __.U ], +# *posargs: __.typx.Any, +# **nomargs: __.typx.Any, +# ) -> __.U: +# self = original( cls, *posargs, **nomargs ) +# _activate_instance_behaviors( +# cls_, self, behaviors_name, behaviors ) +# return self +# +# cls_.__new__ = initialize_with_original +# +# return cls_ +# +# return decorate + + +def produce_instances_initialization_decorator( attributes_namer: _nomina.AttributesNamer, - error_class_provider: _nomina.ErrorClassProvider, - implementation_core: _nomina.AssignerCore, -) -> _nomina.Decorator: - def decorate( cls: type[ _U ] ) -> type[ _U ]: - assigner_name = attributes_namer( level, 'assigner' ) - extant = getattr( cls, assigner_name, None ) - original = getattr( cls, '__setattr__' ) - if extant is original: return cls - - @__.funct.wraps( original ) - def assign( self: object, name: str, value: __.typx.Any ) -> None: - implementation_core( - self, - ligation = __.funct.partial( original, self ), - attributes_namer = attributes_namer, - error_class_provider = error_class_provider, - level = level, - name = name, value = value ) + behaviors: __.cabc.MutableSet[ str ], + ignore_init_arguments: bool, +) -> _nomina.Decorator[ __.U ]: + ''' Produces decorator to inject '__init__' method into class. ''' + def decorate( cls: type[ __.U ] ) -> type[ __.U ]: + behaviors_name = attributes_namer( 'instance', 'behaviors' ) + original = cls.__dict__.get( '__init__' ) - setattr( cls, assigner_name, assign ) - cls.__setattr__ = assign - return cls + if original is None: - return decorate + def initialize_with_super( + self: object, *posargs: __.typx.Any, **nomargs: __.typx.Any + ) -> None: + if ignore_init_arguments: super( cls, self ).__init__( ) + else: super( cls, self ).__init__( *posargs, **nomargs ) + _activate_instance_behaviors( + cls, self, behaviors_name, behaviors ) + cls.__init__ = initialize_with_super -def produce_attributes_deletion_decorator( - level: str, - attributes_namer: _nomina.AttributesNamer, - error_class_provider: _nomina.ErrorClassProvider, - implementation_core: _nomina.DeleterCore, -) -> _nomina.Decorator: - def decorate( cls: type[ _U ] ) -> type[ _U ]: - deleter_name = attributes_namer( level, 'deleter' ) - extant = getattr( cls, deleter_name, None ) - original = getattr( cls, '__delattr__' ) - if extant is original: return cls - - @__.funct.wraps( original ) - def delete( self: object, name: str ) -> None: - implementation_core( - self, - ligation = __.funct.partial( original, self ), - attributes_namer = attributes_namer, - error_class_provider = error_class_provider, - level = level, - name = name ) + else: + + @__.funct.wraps( original ) + def initialize_with_original( + self: object, *posargs: __.typx.Any, **nomargs: __.typx.Any + ) -> None: + if ignore_init_arguments: original( self ) + else: original( self, *posargs, **nomargs ) + _activate_instance_behaviors( + cls, self, behaviors_name, behaviors ) + + cls.__init__ = initialize_with_original - setattr( cls, deleter_name, delete ) - cls.__delattr__ = delete return cls return decorate -def produce_attributes_surveillance_decorator( +def produce_attributes_assignment_decorator( level: str, attributes_namer: _nomina.AttributesNamer, - implementation_core: _nomina.SurveyorCore, -) -> _nomina.Decorator: - def decorate( cls: type[ _U ] ) -> type[ _U ]: - surveyor_name = attributes_namer( level, 'surveyor' ) - extant = getattr( cls, surveyor_name, None ) - original = getattr( cls, '__dir__' ) - if extant is original: return cls - - @__.funct.wraps( original ) - def survey( self: object ) -> __.cabc.Iterable[ str ]: - return implementation_core( - self, - ligation = __.funct.partial( original, self ), - attributes_namer = attributes_namer, - level = level ) + error_class_provider: _nomina.ErrorClassProvider, + implementation_core: __.typx.Optional[ _nomina.AssignerCore ], +) -> _nomina.Decorator[ __.U ]: + ''' Produces decorator to inject '__setattr__' method into class. ''' + def decorate( cls: type[ __.U ] ) -> type[ __.U ]: + leveli = 'class' if level == 'classes' else level + original = cls.__dict__.get( '__setattr__' ) + core = _behaviors.access_core_function( + cls, + attributes_namer = attributes_namer, + arguments = { f"{level}_assigner": implementation_core }, + level = level, name = 'assigner', + default = _behaviors.assign_attribute_if_mutable ) + + if original is None: + + def assign_with_super( + self: object, name: str, value: __.typx.Any + ) -> None: + ligation = super( cls, self ).__setattr__ + # Only enforce behaviors at start of MRO. + if cls is not type( self ): + ligation( name, value ) + return + core( + self, + ligation = ligation, + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + level = leveli, + name = name, value = value ) + + cls.__setattr__ = assign_with_super + + else: + + @__.funct.wraps( original ) + def assign_with_original( + self: object, name: str, value: __.typx.Any + ) -> None: + ligation = __.funct.partial( original, self ) + # Only enforce behaviors at start of MRO. + if cls is not type( self ): + ligation( name, value ) + return + core( + self, + ligation = ligation, + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + level = leveli, + name = name, value = value ) + + cls.__setattr__ = assign_with_original - setattr( cls, surveyor_name, survey ) - cls.__dir__ = survey return cls return decorate -def produce_decorators_factory( # noqa: PLR0913 +def produce_attributes_deletion_decorator( level: str, - attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, - error_class_provider: _nomina.ErrorClassProvider = __.provide_error_class, - assigner_core: _nomina.AssignerCore = ( - _behaviors.assign_attribute_if_mutable ), - deleter_core: _nomina.DeleterCore = ( - _behaviors.delete_attribute_if_mutable ), - surveyor_core: _nomina.SurveyorCore = ( - _behaviors.survey_visible_attributes ), -) -> __.cabc.Callable[ - [ - _nomina.BehaviorExclusionVerifiersOmni, - _nomina.BehaviorExclusionVerifiersOmni - ], - _nomina.Decorators -]: - def produce( - mutables: _nomina.BehaviorExclusionVerifiersOmni, - visibles: _nomina.BehaviorExclusionVerifiersOmni, - ) -> _nomina.Decorators: - ''' Produces standard decorators. ''' - decorators: list[ _nomina.Decorator ] = [ ] - decorators.append( - produce_instances_initialization_decorator( - attributes_namer = attributes_namer, - mutables = mutables, visibles = visibles ) ) - if mutables != '*': - decorators.append( - produce_attributes_assignment_decorator( - level = level, + attributes_namer: _nomina.AttributesNamer, + error_class_provider: _nomina.ErrorClassProvider, + implementation_core: __.typx.Optional[ _nomina.DeleterCore ], +) -> _nomina.Decorator[ __.U ]: + ''' Produces decorator to inject '__delattr__' method into class. ''' + def decorate( cls: type[ __.U ] ) -> type[ __.U ]: + leveli = 'class' if level == 'classes' else level + original = cls.__dict__.get( '__delattr__' ) + core = _behaviors.access_core_function( + cls, + attributes_namer = attributes_namer, + arguments = { f"{level}_deleter": implementation_core }, + level = level, name = 'deleter', + default = _behaviors.delete_attribute_if_mutable ) + + if original is None: + + def delete_with_super( self: object, name: str ) -> None: + ligation = super( cls, self ).__delattr__ + # Only enforce behaviors at start of MRO. + if cls is not type( self ): + ligation( name ) + return + core( + self, + ligation = ligation, attributes_namer = attributes_namer, error_class_provider = error_class_provider, - implementation_core = assigner_core ) ) - decorators.append( - produce_attributes_deletion_decorator( - level = level, + level = leveli, + name = name ) + + cls.__delattr__ = delete_with_super + + else: + + @__.funct.wraps( original ) + def delete_with_original( self: object, name: str ) -> None: + ligation = __.funct.partial( original, self ) + # Only enforce behaviors at start of MRO. + if cls is not type( self ): + ligation( name ) + return + core( + self, + ligation = ligation, attributes_namer = attributes_namer, error_class_provider = error_class_provider, - implementation_core = deleter_core ) ) - if visibles != '*': - decorators.append( - produce_attributes_surveillance_decorator( - level = level, - attributes_namer = attributes_namer, - implementation_core = surveyor_core ) ) - return decorators + level = leveli, + name = name ) - return produce + cls.__delattr__ = delete_with_original + return cls -def produce_decoration_preparers_factory( - attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, - error_class_provider: _nomina.ErrorClassProvider = __.provide_error_class, - class_preparer: __.typx.Optional[ _nomina.ClassPreparer ] = None, -) -> __.cabc.Callable[ [ ], _nomina.DecorationPreparers ]: - def produce( ) -> _nomina.DecorationPreparers: - ''' Produces processors for standard decorators. ''' - preprocessors: list[ _nomina.DecorationPreparer ] = [ ] - if class_preparer is not None: - preprocessors.append( - __.funct.partial( - class_preparer, - attributes_namer = attributes_namer ) ) - return tuple( preprocessors ) + return decorate + + +def produce_attributes_surveillance_decorator( + level: str, + attributes_namer: _nomina.AttributesNamer, + implementation_core: __.typx.Optional[ _nomina.SurveyorCore ], +) -> _nomina.Decorator[ __.U ]: + ''' Produces decorator to inject '__dir__' method into class. ''' + def decorate( cls: type[ __.U ] ) -> type[ __.U ]: + leveli = 'class' if level == 'classes' else level + original = cls.__dict__.get( '__dir__' ) + core = _behaviors.access_core_function( + cls, + attributes_namer = attributes_namer, + arguments = { f"{level}_surveyor": implementation_core }, + level = level, name = 'surveyor', + default = _behaviors.survey_visible_attributes ) + + if original is None: + + def survey_with_super( + self: object + ) -> __.cabc.Iterable[ str ]: + ligation = super( cls, self ).__dir__ + # Only enforce behaviors at start of MRO. + if cls is not type( self ): return ligation( ) + return core( + self, + ligation = ligation, + attributes_namer = attributes_namer, + level = leveli ) + + cls.__dir__ = survey_with_super + + else: + + @__.funct.wraps( original ) + def survey_with_original( + self: object + ) -> __.cabc.Iterable[ str ]: + ligation = __.funct.partial( original, self ) + # Only enforce behaviors at start of MRO. + if cls is not type( self ): return ligation( ) + return core( + self, + ligation = ligation, + attributes_namer = attributes_namer, + level = leveli ) - return produce + cls.__dir__ = survey_with_original + return cls -class_factory_decorators = produce_class_factory_decorators( ) + return decorate @__.typx.dataclass_transform( frozen_default = True, kw_only_default = True ) -def dataclass_with_standard_behaviors( - decorators: _nomina.Decorators = ( ), +def dataclass_with_standard_behaviors( # noqa: PLR0913, PLR0917 + attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, + error_class_provider: _nomina.ErrorClassProvider = __.provide_error_class, + decorators: _nomina.Decorators[ __.U ] = ( ), + assigner_core: __.typx.Optional[ _nomina.AssignerCore ] = None, + deleter_core: __.typx.Optional[ _nomina.DeleterCore ] = None, + surveyor_core: __.typx.Optional[ _nomina.SurveyorCore ] = None, + ignore_init_arguments: bool = False, mutables: _nomina.BehaviorExclusionVerifiersOmni = __.mutables_default, visibles: _nomina.BehaviorExclusionVerifiersOmni = __.visibles_default, -) -> _nomina.Decorator: +) -> _nomina.Decorator[ __.U ]: # https://github.com/microsoft/pyright/discussions/10344 ''' Dataclass decorator factory. ''' - decorators_factory = produce_decorators_factory( level = 'instances' ) - decorators_ = decorators_factory( mutables, visibles ) - preparers_factory = produce_decoration_preparers_factory( - class_preparer = prepare_dataclass_for_instances ) - preparers = preparers_factory( ) + decorators_: _nomina.Decorators[ __.U ] = ( + _produce_instances_decorators( + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + assigner_core = assigner_core, + deleter_core = deleter_core, + surveyor_core = surveyor_core, + ignore_init_arguments = ignore_init_arguments, + mutables = mutables, + visibles = visibles ) ) + preparers: _nomina.DecorationPreparers[ __.U ] = ( + _produce_instances_decoration_preparers( + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + class_preparer = prepare_dataclass_for_instances ) ) return decoration_by( *decorators, _dataclass_core, *decorators_, preparers = preparers ) -def with_standard_behaviors( - decorators: _nomina.Decorators = ( ), +def with_standard_behaviors( # noqa: PLR0913, PLR0917 + attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, + error_class_provider: _nomina.ErrorClassProvider = __.provide_error_class, + decorators: _nomina.Decorators[ __.U ] = ( ), + assigner_core: __.typx.Optional[ _nomina.AssignerCore ] = None, + deleter_core: __.typx.Optional[ _nomina.DeleterCore ] = None, + surveyor_core: __.typx.Optional[ _nomina.SurveyorCore ] = None, + ignore_init_arguments: bool = False, mutables: _nomina.BehaviorExclusionVerifiersOmni = __.mutables_default, visibles: _nomina.BehaviorExclusionVerifiersOmni = __.visibles_default, -) -> _nomina.Decorator: +) -> _nomina.Decorator[ __.U ]: ''' Class decorator factory. ''' - decorators_factory = produce_decorators_factory( level = 'instances' ) - decorators_ = decorators_factory( mutables, visibles ) - preparers_factory = produce_decoration_preparers_factory( ) - preparers = preparers_factory( ) + decorators_: _nomina.Decorators[ __.U ] = ( + _produce_instances_decorators( + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + assigner_core = assigner_core, + deleter_core = deleter_core, + surveyor_core = surveyor_core, + ignore_init_arguments = ignore_init_arguments, + mutables = mutables, + visibles = visibles ) ) + preparers: _nomina.DecorationPreparers[ __.U ] = ( + _produce_instances_decoration_preparers( + attributes_namer = attributes_namer, + error_class_provider = error_class_provider ) ) return decoration_by( *decorators, *decorators_, preparers = preparers ) + + +def _activate_instance_behaviors( + cls: type[ __.U ], + self: object, + behaviors_name: str, + behaviors: __.cabc.MutableSet[ str ], +) -> None: + # Only record behaviors at start of MRO. + if cls is not type( self ): return + behaviors_: set[ str ] = ( + _utilities.getattr0( self, behaviors_name, set( ) ) ) + behaviors_.update( behaviors ) + _utilities.setattr0( self, behaviors_name, frozenset( behaviors_ ) ) + + +def _produce_instances_decoration_preparers( + attributes_namer: _nomina.AttributesNamer, + error_class_provider: _nomina.ErrorClassProvider, + class_preparer: __.typx.Optional[ _nomina.ClassPreparer ] = None, +) -> _nomina.DecorationPreparers[ __.U ]: + ''' Produces processors for standard decorators. ''' + preprocessors: list[ _nomina.DecorationPreparer[ __.U ] ] = [ ] + if class_preparer is not None: + preprocessors.append( + __.funct.partial( + class_preparer, attributes_namer = attributes_namer ) ) + return tuple( preprocessors ) + + +def _produce_instances_decorators( # noqa: PLR0913, PLR0917 + attributes_namer: _nomina.AttributesNamer, + error_class_provider: _nomina.ErrorClassProvider, + assigner_core: __.typx.Optional[ _nomina.AssignerCore ], + deleter_core: __.typx.Optional[ _nomina.DeleterCore ], + surveyor_core: __.typx.Optional[ _nomina.SurveyorCore ], + ignore_init_arguments: bool, + mutables: _nomina.BehaviorExclusionVerifiersOmni, + visibles: _nomina.BehaviorExclusionVerifiersOmni, +) -> _nomina.Decorators[ __.U ]: + ''' Produces standard decorators. ''' + decorators: list[ _nomina.Decorator[ __.U ] ] = [ ] + decorators.append( + produce_instances_inception_decorator( + attributes_namer = attributes_namer, + assigner_core = assigner_core, + deleter_core = deleter_core, + surveyor_core = surveyor_core, + ignore_init_arguments = ignore_init_arguments, + mutables = mutables, visibles = visibles ) ) + decorators.append( + produce_attributes_assignment_decorator( + level = 'instances', + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + implementation_core = assigner_core ) ) + decorators.append( + produce_attributes_deletion_decorator( + level = 'instances', + attributes_namer = attributes_namer, + error_class_provider = error_class_provider, + implementation_core = deleter_core ) ) + decorators.append( + produce_attributes_surveillance_decorator( + level = 'instances', + attributes_namer = attributes_namer, + implementation_core = surveyor_core ) ) + return decorators diff --git a/sources/classcore/standard/dynadoc.py b/sources/classcore/standard/dynadoc.py new file mode 100644 index 0000000..a9eace7 --- /dev/null +++ b/sources/classcore/standard/dynadoc.py @@ -0,0 +1,129 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +''' Dynadoc integration. ''' + + +from .. import utilities as _utilities +from . import __ +from . import nomina as _nomina + + +dynadoc_context = __.ddoc.produce_context( ) +dynadoc_class_introspection_control = ( + __.ddoc.ClassIntrospectionControl( + inheritance = True, + introspectors = ( + __.ddoc.introspection.introspect_special_classes, ) ) ) +dynadoc_module_introspection_control = ( + __.ddoc.ModuleIntrospectionControl( ) ) + + +def dynadoc_avoid_immutables( + objct: object, + introspection: __.ddoc.IntrospectionControl, + attributes_namer: _nomina.AttributesNamer, +) -> __.ddoc.IntrospectionControl: + ''' Disables introspection of immutable objects. ''' + if __.inspect.isclass( objct ): + behaviors_name = attributes_namer( 'class', 'behaviors' ) + behaviors = _utilities.getattr0( objct, behaviors_name, frozenset( ) ) + if _nomina.immutability_label in behaviors: + return introspection.with_limit( + __.ddoc.IntrospectionLimit( disable = True ) ) + return introspection + + +def produce_dynadoc_introspection_limiter( + attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, +) -> __.ddoc.IntrospectionLimiter: + ''' Produces introspection limiter which avoids immutable objects. ''' + return __.funct.partial( + dynadoc_avoid_immutables, attributes_namer = attributes_namer ) + +dynadoc_introspection_limiter = produce_dynadoc_introspection_limiter( ) + + +def produce_dynadoc_introspection_control( + enable: bool = True, + class_control: __.ddoc.ClassIntrospectionControl = ( + dynadoc_class_introspection_control ), + module_control: __.ddoc.ModuleIntrospectionControl = ( + dynadoc_module_introspection_control ), + limiters: __.ddoc.IntrospectionLimiters = ( + dynadoc_introspection_limiter, ), + targets: __.ddoc.IntrospectionTargets = ( + __.ddoc.IntrospectionTargetsSansModule ), +) -> __.ddoc.IntrospectionControl: + ''' Produces compatible Dynadoc introspection control. ''' + return __.ddoc.IntrospectionControl( + enable = enable, + class_control = class_control, + module_control = module_control, + limiters = limiters, + targets = targets ) + +dynadoc_introspection_on_class = produce_dynadoc_introspection_control( ) +dynadoc_introspection_on_package = ( + produce_dynadoc_introspection_control( + targets = __.ddoc.IntrospectionTargetsOmni ) ) + + +def assign_module_docstring( # noqa: PLR0913 + module: str | __.types.ModuleType, /, + *fragments: __.ddoc.interfaces.Fragment, + context: _nomina.DynadocContextArgument = dynadoc_context, + introspection: _nomina.DynadocIntrospectionArgument = ( + dynadoc_introspection_on_package ), + preserve: _nomina.DynadocPreserveArgument = True, + renderer: __.ddoc.xtnsapi.Renderer = ( + __.ddoc.assembly.renderer_default ), + table: _nomina.DynadocTableArgument = __.dictproxy_empty, +) -> None: + ''' Updates module docstring based on introspection. + + By default, recursively updates docstrings of all module members + which have docstrings. + + By default, ignores previously-decorated immutable classes. + ''' + __.ddoc.assign_module_docstring( + module, + *fragments, + context = context, + introspection = introspection, + preserve = preserve, + renderer = renderer, + table = table ) + + +def produce_dynadoc_configuration( + context: _nomina.DynadocContextArgument = dynadoc_context, + introspection: _nomina.DynadocIntrospectionArgument = ( + dynadoc_introspection_on_class ), + preserve: _nomina.DynadocPreserveArgument = True, + table: _nomina.DynadocTableArgument = __.dictproxy_empty, +) -> _nomina.ProduceDynadocConfigurationReturn: + ''' Produces compatible Dynadoc configuration. ''' + return __.types.MappingProxyType( dict( + context = context, + introspection = introspection, + preserve = preserve, + table = table ) ) diff --git a/sources/classcore/standard/explanations.py b/sources/classcore/standard/explanations.py new file mode 100644 index 0000000..62d270b --- /dev/null +++ b/sources/classcore/standard/explanations.py @@ -0,0 +1,253 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +''' Explanations of attribute behavior decisions. ''' + + +from .. import utilities as _utilities +from . import __ +from . import behaviors as _behaviors +from . import decorators as _decorators +from . import nomina as _nomina + + +_framework_names_prefix = '_classcore_' + + +@_decorators.dataclass_with_standard_behaviors( ) +class Decision: + ''' Decision which one operation reached for one attribute. + + The decision subclasses form a closed hierarchy; the decision + type is the discriminant. Verdict permissibility is derived + from the decision type. + ''' + + +@_decorators.dataclass_with_standard_behaviors( ) +class PermitByInapplicability( Decision ): + ''' Permission because the governing behavior is inactive. ''' + + +@_decorators.dataclass_with_standard_behaviors( ) +class PermitByOmni( Decision ): + ''' Permission by the omni marker, which permits every name. ''' + + +@_decorators.dataclass_with_standard_behaviors( ) +class PermitByNames( Decision ): + ''' Permission by exclusion names membership. ''' + name: str + + +@_decorators.dataclass_with_standard_behaviors( ) +class PermitByPredicate( Decision ): + ''' Permission by an exclusion predicate. ''' + predicate: str + + +@_decorators.dataclass_with_standard_behaviors( ) +class PermitByRegex( Decision ): + ''' Permission by an exclusion regex. ''' + pattern: str + + +@_decorators.dataclass_with_standard_behaviors( ) +class Prohibit( Decision ): + ''' Prohibition: the behavior is active and no rule permits. ''' + + +@_decorators.dataclass_with_standard_behaviors( ) +class Verdict: + ''' Verdict for one operation on one attribute. ''' + operation: str + decision: Decision + + @property + def permissible( self ) -> bool: + ''' Returns whether the operation is permissible. ''' + return not isinstance( self.decision, Prohibit ) + + +@_decorators.dataclass_with_standard_behaviors( ) +class AssignVerdict( Verdict ): + ''' Verdict for attribute assignment. ''' + + +@_decorators.dataclass_with_standard_behaviors( ) +class DeleteVerdict( Verdict ): + ''' Verdict for attribute deletion. ''' + + +@_decorators.dataclass_with_standard_behaviors( ) +class SurveyVerdict( Verdict ): + ''' Verdict for attribute visibility in survey results. ''' + + +@_decorators.dataclass_with_standard_behaviors( ) +class AttributeExplanation: + ''' Decision trace for one attribute of one target. ''' + target: str + name: str + behaviors: __.cabc.Mapping[ str, __.cabc.Set[ str ] ] + operations: __.cabc.Mapping[ str, Verdict ] + internal: bool + + def __repr__( self ) -> str: + ''' Returns summary of the decision trace. ''' + lines = [ + f"{self.name!r} on {self.target}" + + ( ' [internal]' if self.internal else '' ) ] + for level, labels in self.behaviors.items( ): + lines.append( + f"behaviors: {', '.join( sorted( labels ) )}" + + f" ({level})" ) + for operation in ( 'assign', 'delete', 'survey' ): + verdict = self.operations.get( operation ) + if verdict is None: continue # pragma: no cover + lines.append( f"{operation}: {self._render( verdict.decision )}" ) + return '\n'.join( lines ) + + def _render( self, decision: Decision, / ) -> str: + if isinstance( decision, PermitByInapplicability ): + return 'permitted (behavior inapplicable)' + if isinstance( decision, PermitByOmni ): + return "permitted by omni '*'" + if isinstance( decision, PermitByNames ): + return f"permitted by names {decision.name!r}" + if isinstance( decision, PermitByPredicate ): + return f"permitted by predicate {decision.predicate!r}" + if isinstance( decision, PermitByRegex ): + return f"permitted by regex {decision.pattern!r}" + if isinstance( decision, Prohibit ): + return 'prohibited (no permitting rule)' + raise ValueError( type( decision ) ) + + +def explain_attribute( + target: object, name: str, /, *, + attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, +) -> AttributeExplanation: + ''' Returns decision trace for attribute name on target. + + All operations follow precedence semantics: omni, then names + membership, then the first matching predicate, then the first + matching regex; prohibition when the governing behavior is + active and nothing permits; permission by inapplicability when + the behavior is inactive. For class targets, the classes-level + configuration is evaluated; for instance targets, the + instances-level configuration is evaluated against the + instance's class hierarchy — matching the levels the behavior + cores evaluate. Records report the normalized level names + ('class' and 'instance') at which behaviors and exclusion + configuration were found. Explanations are observational: they + neither bypass nor alter concealment or immutability. They are + not side-effect-free for arbitrary callbacks, however: + producing a record invokes user predicates and internal-name + detectors to determine the same outcome the cores would. + + Survey explanations describe normalized first-match semantics; + the survey core's current once-per-matching-rule duplication is + characterized as a defect whose repair is deferred separately. + ''' + class_target = __.inspect.isclass( target ) + level = 'class' if class_target else 'instances' + level_normalized = 'class' if class_target else 'instance' + behaviors = _behaviors.survey_active_behaviors( + target, attributes_namer = attributes_namer, level = level ) + immutability = _nomina.immutability_label in behaviors + concealment = _nomina.concealment_label in behaviors + operations: dict[ str, Verdict ] = { } + for operation, verdict_class, basename, active in ( + ( 'assign', AssignVerdict, 'mutables', immutability ), + ( 'delete', DeleteVerdict, 'mutables', immutability ), + ( 'survey', SurveyVerdict, 'visibles', concealment ), + ): + rule = ( + _behaviors.provide_permitter_if_applicable( + target, attributes_namer = attributes_namer, + level = level, basename = basename, name = name ) + if active else None ) + operations[ operation ] = verdict_class( + operation = operation, + decision = produce_decision( rule, active ) ) + return AttributeExplanation( + target = _utilities.describe_object( target ), + name = name, + behaviors = __.types.MappingProxyType( + { level_normalized: frozenset( behaviors ) } ), + operations = __.types.MappingProxyType( operations ), + internal = is_internal_name( target, name ) ) + + +def is_internal_name( target: object, name: str, / ) -> bool: + ''' Returns whether name is internal to the target's machinery. + + Contributions are discovered on the metaclass chain: for a + class target, its metaclass method resolution order; for an + instance target, the metaclass resolution order of its type + (the metaclass chain is reached through the double type + transformation — the instance's own type resolution order + enumerates the class bases, never the metaclass). Decorator- + based classes carry their contributions on the class itself, + so the class resolution order is consulted as well. Each + contribution is a detector produced by the machinery which + built the class, so a name is internal only where the + machinery that generates it operates. Classification scope is + the machinery chain and is independent of per-class mutability + exemptions: a name may be internal on a class where it is not + mutable, and mutable where it is not internal. + ''' + cls = target if __.inspect.isclass( target ) else type( target ) + chains = ( + type( cls ).__mro__, cls.__mro__ ) # Metaclass chain, then class. + contribution_name = __.calculate_contribution_name( ) + for chain in chains: + for holder in chain: + contributions = holder.__dict__.get( contribution_name ) + if not contributions: continue + for detector in contributions: + if detector( name ): return True + return False + + +def produce_decision( + rule: __.typx.Optional[ tuple[ str, __.typx.Any ] ], active: bool, / +) -> Decision: + ''' Returns decision for governing-behavior activity and first rule. + + Rules are the (kind, raw verifier) pairs produced by + provide_permitter_if_applicable; the kind vocabulary matches the + omni/names/predicate/regex precedence stages. Verifier detail + text is rendered here, at the record layer, so enforcement + paths which consult the same rules never depend on rendering. + ''' + if not active: return PermitByInapplicability( ) + if rule is None: return Prohibit( ) + kind, verifier = rule + if kind == 'omni': return PermitByOmni( ) + if kind == 'names': return PermitByNames( name = verifier ) + if kind == 'predicate': + return PermitByPredicate( + predicate = _behaviors.render_verifier_text( verifier ) ) + if kind == 'regex': + return PermitByRegex( + pattern = _behaviors.render_verifier_text( verifier ) ) + raise ValueError( kind ) diff --git a/sources/classcore/standard/modules.py b/sources/classcore/standard/modules.py index e0f6b5e..c33ebc4 100644 --- a/sources/classcore/standard/modules.py +++ b/sources/classcore/standard/modules.py @@ -21,69 +21,189 @@ ''' Standard module classes and reclassifers. ''' -from __future__ import annotations - +from .. import utilities as _utilities from . import __ -from . import behaviors as _behaviors from . import classes as _classes +from . import dynadoc as _dynadoc from . import nomina as _nomina -class Module( __.types.ModuleType, _classes.Object ): +class Module( _classes.Object, __.types.ModuleType ): ''' Modules with attributes immutability and concealment. ''' +def finalize_module( # noqa: PLR0913 + module: __.typx.Annotated[ + str | __.types.ModuleType, + __.ddoc.Doc( ''' Module or module name to finalize. ''' ), + ], /, + *fragments: __.ddoc.interfaces.Fragment, + attributes_namer: _nomina.AttributesNamer = __.calculate_attrname, + dynadoc_introspection: _nomina.DynadocIntrospectionArgument = ( + _dynadoc.dynadoc_introspection_on_package ), + dynadoc_table: _nomina.DynadocTableArgument = __.dictproxy_empty, + excludes: __.typx.Annotated[ + __.typx.Optional[ __.cabc.MutableSet[ __.types.ModuleType ] ], + __.ddoc.Doc( ''' Modules to exclude from reclassification. ''' ), + ] = None, + recursive: __.typx.Annotated[ + bool, __.ddoc.Doc( ''' Recursively reclassify package modules? ''' ) + ] = False, + replacement_class: __.typx.Annotated[ + type[ __.types.ModuleType ], + __.ddoc.Doc( ''' New class for module. ''' ), + ] = Module, +) -> None: + ''' Combines Dynadoc docstring assignment and module reclassification. + + Applies module docstring generation via Dynadoc introspection, + then reclassifies modules for immutability and concealment. + + When recursive is False, automatically excludes module targets from + dynadoc introspection to document only the provided module. When + recursive is True, automatically includes module targets so Dynadoc + can recursively document all modules. + ''' + module_target = __.ddoc.IntrospectionTargets.Module + if recursive: + if not ( dynadoc_introspection.targets & module_target ): + targets = dynadoc_introspection.targets | module_target + introspection = __.ddoc.IntrospectionControl( + enable = dynadoc_introspection.enable, + class_control = dynadoc_introspection.class_control, + module_control = dynadoc_introspection.module_control, + limiters = dynadoc_introspection.limiters, + targets = targets ) + else: introspection = dynadoc_introspection + elif dynadoc_introspection.targets & module_target: + limit = __.ddoc.IntrospectionLimit( + targets_exclusions = module_target ) + introspection = dynadoc_introspection.with_limit( limit ) + else: introspection = dynadoc_introspection + _dynadoc.assign_module_docstring( + module, + *fragments, + introspection = introspection, + table = dynadoc_table ) + _reclassify_module( + module, + attributes_namer = attributes_namer, + excludes = excludes, recursive = recursive, + replacement_class = replacement_class ) + + +@__.typx.deprecated( "Use 'finalize_module' instead." ) def reclassify_modules( attributes: __.typx.Annotated[ __.cabc.Mapping[ str, __.typx.Any ] | __.types.ModuleType | str, - __.typx.Doc( - 'Module, module name, or dictionary of object attributes.' ), + __.ddoc.Doc( + ''' Module, module name, or dictionary of object attributes. ''' ), ], /, *, attributes_namer: __.typx.Annotated[ _nomina.AttributesNamer, - __.typx.Doc( + __.ddoc.Doc( ''' Attributes namer function with which to seal class. ''' ), ] = __.calculate_attrname, + excludes: __.typx.Annotated[ + __.typx.Optional[ __.cabc.MutableSet[ __.types.ModuleType ] ], + __.ddoc.Doc( ''' Modules to exclude from reclassification. ''' ), + ] = None, recursive: __.typx.Annotated[ - bool, __.typx.Doc( 'Recursively reclassify package modules?' ) + bool, __.ddoc.Doc( ''' Recursively reclassify package modules? ''' ) ] = False, + replacement_class: __.typx.Annotated[ + type[ __.types.ModuleType ], + __.ddoc.Doc( ''' New class for module. ''' ), + ] = Module, ) -> None: - # TODO? Ensure correct operation with namespace packages. ''' Reclassifies modules to have attributes concealment and immutability. Can operate on individual modules or entire package hierarchies. - Notes - ----- - * Only converts modules within the same package to prevent unintended - modifications to external modules. - * When used with a dictionary, converts any module objects found as - values if they belong to the same package. - * Has no effect on already-reclassified modules. + Only converts modules within the same package to prevent unintended + modifications to external modules. + + When used with a dictionary, converts any module objects found as + values if they belong to the same package. + + Has no effect on already-reclassified modules. + ''' + _reclassify_module( + attributes, + attributes_namer = attributes_namer, + excludes = excludes, recursive = recursive, + replacement_class = replacement_class ) + + +def _reclassify_module( # noqa: C901,PLR0912 + attributes: __.typx.Annotated[ + __.cabc.Mapping[ str, __.typx.Any ] | __.types.ModuleType | str, + __.ddoc.Doc( + ''' Module, module name, or dictionary of object attributes. ''' ), + ], /, *, + attributes_namer: __.typx.Annotated[ + _nomina.AttributesNamer, + __.ddoc.Doc( + ''' Attributes namer function with which to seal class. ''' ), + ] = __.calculate_attrname, + excludes: __.typx.Annotated[ + __.typx.Optional[ __.cabc.MutableSet[ __.types.ModuleType ] ], + __.ddoc.Doc( ''' Modules to exclude from reclassification. ''' ), + ] = None, + recursive: __.typx.Annotated[ + bool, __.ddoc.Doc( ''' Recursively reclassify package modules? ''' ) + ] = False, + replacement_class: __.typx.Annotated[ + type[ __.types.ModuleType ], + __.ddoc.Doc( ''' New class for module. ''' ), + ] = Module, +) -> None: + # TODO? Ensure correct operation with namespace packages. + ''' Core implementation for module reclassification. + + Reclassifies modules to have attributes concealment and immutability. + Can operate on individual modules or entire package hierarchies. + + Only converts modules within the same package to prevent unintended + modifications to external modules. + + When used with a dictionary, converts any module objects found as + values if they belong to the same package. + + Has no effect on already-reclassified modules. ''' if isinstance( attributes, str ): attributes = __.sys.modules[ attributes ] if isinstance( attributes, __.types.ModuleType ): module = attributes - attributes = attributes.__dict__ + if excludes and module in excludes: return + attributes = module.__dict__ else: module = None + if excludes is None: excludes = set( ) + if module: excludes.add( module ) package_name = ( attributes.get( '__package__' ) or attributes.get( '__name__' ) ) if not package_name: return for value in attributes.values( ): if not __.inspect.ismodule( value ): continue if not value.__name__.startswith( f"{package_name}." ): continue - if recursive: reclassify_modules( value, recursive = True ) - if isinstance( value, Module ): continue - _seal_module( value, attributes_namer ) - if module and not isinstance( module, Module ): - _seal_module( module, attributes_namer ) + if isinstance( value, replacement_class ): continue + if recursive: + _reclassify_module( + value, + attributes_namer = attributes_namer, + excludes = excludes, recursive = True, + replacement_class = replacement_class ) + if module and not isinstance( module, replacement_class ): + _seal_module( module, attributes_namer, replacement_class ) def _seal_module( - module: __.types.ModuleType, attributes_namer: _nomina.AttributesNamer + module: __.types.ModuleType, + attributes_namer: _nomina.AttributesNamer, + replacement_class: type[ __.types.ModuleType ], ) -> None: - behaviors = { _behaviors.concealment_label, _behaviors.immutability_label } + behaviors = { _nomina.concealment_label, _nomina.immutability_label } behaviors_name = attributes_namer( 'instance', 'behaviors' ) - setattr( module, behaviors_name, behaviors ) - module.__class__ = Module + module.__class__ = replacement_class + _utilities.setattr0( module, behaviors_name, behaviors ) diff --git a/sources/classcore/standard/nomina.py b/sources/classcore/standard/nomina.py index 6fd0545..1e53351 100644 --- a/sources/classcore/standard/nomina.py +++ b/sources/classcore/standard/nomina.py @@ -18,14 +18,20 @@ #============================================================================# -''' Catalog of common type aliases. ''' +''' Catalog of common names and type aliases. ''' # ruff: noqa: F403,F405 -from __future__ import annotations - -from . import __ from ..nomina import * +from . import __ + +concealment_label = 'concealment' +immutability_label = 'immutability' + + +abc_class_mutables = __.abc_class_mutables +typing_class_mutables = __.typing_class_mutables +AttrnameCalculator = __.AttrnameCalculator BehaviorExclusionNames: __.typx.TypeAlias = __.cabc.Set[ str ] @@ -44,8 +50,19 @@ __.cabc.Sequence[ BehaviorExclusionVerifier ] ) BehaviorExclusionVerifiersOmni: __.typx.TypeAlias = ( BehaviorExclusionVerifiers | __.typx.Literal[ '*' ] ) -ErrorClassProvider: __.typx.TypeAlias = ( - __.cabc.Callable[ [ str ], type[ Exception ] ] ) +InternalNameDetector: __.typx.TypeAlias = ( + __.cabc.Callable[ [ str ], bool ] ) +InternalNameDetectors: __.typx.TypeAlias = ( + __.cabc.Sequence[ InternalNameDetector ] ) +ErrorClassProvider: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Callable[ [ str ], type[ Exception ] ], + __.ddoc.Doc( + ''' Takes name of exception class and returns corresponding class. + + Can be used by downstream packages to provide exceptions from their + own hierarchies rather than the hierarchy from this package. + ''' ), +] class AssignerCore( __.typx.Protocol ): @@ -95,6 +112,45 @@ class ClassPreparer( __.typx.Protocol ): @staticmethod def __call__( # pragma: no branch class_: type, - decorators: DecoratorsMutable, /, *, + decorators: DecoratorsMutable[ __.U ], /, *, attributes_namer: AttributesNamer, ) -> None: raise NotImplementedError + + +DynadocConfiguration: __.typx.TypeAlias = __.cabc.Mapping[ str, __.typx.Any ] +DynadocContextArgument: __.typx.TypeAlias = __.typx.Annotated[ + __.ddoc.Context, + __.ddoc.Doc( + ''' Dynadoc context. + + Renderer, dictionaries for resolution of stringified annotations, + etc.... + ''' ), +] +DynadocIntrospectionArgument: __.typx.TypeAlias = __.typx.Annotated[ + __.ddoc.IntrospectionControl, + __.ddoc.Doc( + ''' Dynadoc introspection control. + + Which kinds of object to recursively introspect? + Scan unnannotated attributes? + Consider base classes? + Etc... + ''' ), +] +DynadocPreserveArgument: __.typx.TypeAlias = __.typx.Annotated[ + bool, __.ddoc.Doc( ''' Preserve existing docstring? ''' ) +] +DynadocTableArgument: __.typx.TypeAlias = __.typx.Annotated[ + __.cabc.Mapping[ str, str ], + __.ddoc.Doc( ''' Table of documentation fragments. ''' ), +] +ProduceDynadocConfigurationReturn: __.typx.TypeAlias = __.typx.Annotated[ + DynadocConfiguration, + __.ddoc.Doc( + ''' Dynadoc configuration dictionary. + + Suitable as a keyword expansion (``**``) argument to + ``assign_module_docstring`` or ``with_docstring``. + ''' ), +] diff --git a/sources/classcore/utilities.py b/sources/classcore/utilities.py index 40c91b6..cdd6b79 100644 --- a/sources/classcore/utilities.py +++ b/sources/classcore/utilities.py @@ -21,37 +21,125 @@ ''' Various utilities for class manipulation. ''' -from __future__ import annotations - from . import __ -def describe_object( obj: object ) -> str: - if __.inspect.isclass( obj ): - return "class '{}'".format( qualify_class_name( obj ) ) +def describe_object( objct: object, / ) -> str: + ''' Returns object type with fully-qualified name. ''' + if __.inspect.isclass( objct ): + return "class '{}'".format( qualify_class_name( objct ) ) # TODO? functions, methods, etc... - return "instance of {}".format( describe_object( type( obj ) ) ) - - -def getattr0( obj: object, name: str, default: __.typx.Any ) -> __.typx.Any: - ''' Returns attribute from object without inheritance. ''' - # Inspect object dictionary directly to suppress getattr inheritance. - attrsdict = getattr( obj, '__dict__', { } ) - if name in attrsdict: return attrsdict[ name ] - slots = getattr( obj, '__slots__', ( ) ) - # Name may be in slots but not yet assigned. - if name in slots: return getattr( obj, name, default ) - return default + return "instance of {}".format( describe_object( type( objct ) ) ) + + +def getattr0( + objct: object, /, name: str, default: __.typx.Any +) -> __.typx.Any: + ''' Returns special private attribute from object. + + This avoids inheritance-related collisions. + + Uses mangled attribute name which is unique to the class, + except when attribute is slotted. Slotted attributes are effectively + isolated from inheritance. + ''' + if not __.inspect.isclass( objct ): + for base in type( objct ).mro( ): + slots = getattr( base, '__slots__', ( ) ) + if name in slots: return getattr( objct, name, default ) + name_m = mangle_name( objct, name ) + return getattr( objct, name_m, default ) + + +def delattr0( objct: object, /, name: str ) -> None: + ''' Deletes special private attribute on object. + + This avoids inheritance-related collisions. + + Uses mangled attribute name which is unique to the class, + except when attribute is slotted. Slotted attributes are effectively + isolated from inheritance. + ''' + if not __.inspect.isclass( objct ): + for base in type( objct ).mro( ): + slots = getattr( base, '__slots__', ( ) ) + if name in slots: + delattr( objct, name ) + return + name_m = mangle_name( objct, name ) + delattr( objct, name_m ) + + +def setattr0( objct: object, /, name: str, value: __.typx.Any ) -> None: + ''' Assigns special private attribute to object. + + This avoids inheritance-related collisions. + + Uses mangled attribute name which is unique to the class, + except when attribute is slotted. Slotted attributes are effectively + isolated from inheritance. + ''' + if not __.inspect.isclass( objct ): + for base in type( objct ).mro( ): + slots = getattr( base, '__slots__', ( ) ) + if name in slots: + setattr( objct, name, value ) + return + name_m = mangle_name( objct, name ) + setattr( objct, name_m, value ) + + +def mangle_name( objct: object, /, name: str ) -> str: + ''' Mangles attribute name so that it is unique. + + Effectively provides name of private member attribute, + which is unique across class inheritance. + ''' + # NOTE: Mangling is based on the fully-qualified class name, not on id, + # because class reproductions (e.g., from dataclass slots) must + # retain identity of private attributes. A reproduction is a new + # object, so id-based mangling would break such identity. + if not __.inspect.isclass( objct ): + return mangle_name( type( objct ), name ) + namehash = __.hashlib.sha256( ) + namehash.update( qualify_class_name( objct ).encode( ) ) + namehash_hex = namehash.hexdigest( ) + return f"{name}{namehash_hex}" + + +def survey_mangled_names( cls: type, /, name: str ) -> tuple[ str, ... ]: + ''' Returns mangled variants of name present in class's own namespace. + + A class reproduction (e.g., from ``dataclass( slots = ... )``) is + created with a bare ``__qualname__``, which is restored only after + the reproduction is constructed. During that window, mangled names + computed from the reproduction differ from those computed from the + original. Detection by prefix survives such qualname drift. + ''' + suffix_start = len( name ) + return tuple( + key for key in cls.__dict__ + if key.startswith( name ) and _is_digest( key[ suffix_start: ] ) ) + + +_digest_length = 64 # Length of a hexadecimal SHA-256 digest. + + +def _is_digest( text: str ) -> bool: + ''' Returns whether text is a hexadecimal SHA-256 digest. ''' + return _digest_length == len( text ) and all( + character in '0123456789abcdef' for character in text ) def qualify_class_name( cls: type ) -> str: + ''' Returns fully-qualified class name. ''' return f"{cls.__module__}.{cls.__qualname__}" def repair_class_reproduction( original: type, reproduction: type ) -> None: ''' Repairs a class reproduction, if necessary. ''' match __.platform.python_implementation( ): - case 'CPython': # pragma: no branch + case 'CPython' | 'PyPy': # pragma: no branch _repair_cpython_class_closures( original, reproduction ) case _: pass # pragma: no cover diff --git a/tests/test_000_classcore/__.py b/tests/test_000_classcore/__.py new file mode 100644 index 0000000..bc13919 --- /dev/null +++ b/tests/test_000_classcore/__.py @@ -0,0 +1,64 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +''' Common test utilities and helpers. ''' + + +import types + +from pathlib import Path + + +PACKAGE_NAME = 'classcore' +PACKAGES_NAMES = ( PACKAGE_NAME, ) + + +_modules_cache: dict[ str, types.ModuleType ] = { } +def cache_import_module( qname: str ) -> types.ModuleType: + ''' Imports module from package by name and caches it. ''' + from importlib import import_module + package_name, *maybe_module_name = qname.rsplit( '.', maxsplit = 1 ) + if not maybe_module_name: arguments = ( qname, ) + else: arguments = ( f".{maybe_module_name[0]}", package_name, ) + if qname not in _modules_cache: + _modules_cache[ qname ] = import_module( *arguments ) + return _modules_cache[ qname ] + + +def _discover_module_names( package_name: str ) -> tuple[ str, ... ]: + package = cache_import_module( package_name ) + if not package.__file__: return ( ) + return tuple( + path.stem + for path in Path( package.__file__ ).parent.glob( '*.py' ) + if path.name not in ( '__init__.py', '__main__.py' ) + and path.is_file( ) ) + + +MODULES_NAMES_BY_PACKAGE_NAME = types.MappingProxyType( { + name: _discover_module_names( name ) for name in PACKAGES_NAMES } ) +PACKAGES_NAMES_BY_MODULE_QNAME = types.MappingProxyType( { + f"{subpackage_name}.{module_name}": subpackage_name + for subpackage_name in PACKAGES_NAMES + for module_name in MODULES_NAMES_BY_PACKAGE_NAME[ subpackage_name ] } ) +MODULES_QNAMES = tuple( PACKAGES_NAMES_BY_MODULE_QNAME.keys( ) ) +MODULES_NAMES_BY_MODULE_QNAME = types.MappingProxyType( { + name: name.rsplit( '.', maxsplit = 1 )[ -1 ] + for name in PACKAGES_NAMES_BY_MODULE_QNAME } ) \ No newline at end of file diff --git a/tests/test_000_classcore/__init__.py b/tests/test_000_classcore/__init__.py index 6c22331..a1dcd7f 100644 --- a/tests/test_000_classcore/__init__.py +++ b/tests/test_000_classcore/__init__.py @@ -18,50 +18,4 @@ #============================================================================# -''' Package of tests. - - Common imports, constants, and utilities for tests. -''' - - -import types - -from pathlib import Path - - -PACKAGE_NAME = 'classcore' -PACKAGES_NAMES = ( PACKAGE_NAME, f"{PACKAGE_NAME}.standard" ) - - -_modules_cache: dict[ str, types.ModuleType ] = { } -def cache_import_module( qname: str ) -> types.ModuleType: - ''' Imports module from package by name and caches it. ''' - from importlib import import_module - package_name, *maybe_module_name = qname.rsplit( '.', maxsplit = 1 ) - if not maybe_module_name: arguments = ( qname, ) - else: arguments = ( f".{maybe_module_name[0]}", package_name, ) - if qname not in _modules_cache: - _modules_cache[ qname ] = import_module( *arguments ) - return _modules_cache[ qname ] - - -def _discover_module_names( package_name: str ) -> tuple[ str, ... ]: - package = cache_import_module( package_name ) - if not package.__file__: return ( ) - return tuple( - path.stem - for path in Path( package.__file__ ).parent.glob( '*.py' ) - if path.name not in ( '__init__.py', '__main__.py' ) - and path.is_file( ) ) - - -MODULES_NAMES_BY_PACKAGE_NAME = types.MappingProxyType( { - name: _discover_module_names( name ) for name in PACKAGES_NAMES } ) -PACKAGES_NAMES_BY_MODULE_QNAME = types.MappingProxyType( { - f"{subpackage_name}.{module_name}": subpackage_name - for subpackage_name in PACKAGES_NAMES - for module_name in MODULES_NAMES_BY_PACKAGE_NAME[ subpackage_name ] } ) -MODULES_QNAMES = tuple( PACKAGES_NAMES_BY_MODULE_QNAME.keys( ) ) -MODULES_NAMES_BY_MODULE_QNAME = types.MappingProxyType( { - name: name.rsplit( '.', maxsplit = 1 )[ -1 ] - for name in PACKAGES_NAMES_BY_MODULE_QNAME } ) +''' Package of tests. ''' diff --git a/tests/test_000_classcore/conftest.py b/tests/test_000_classcore/conftest.py new file mode 100644 index 0000000..939d81d --- /dev/null +++ b/tests/test_000_classcore/conftest.py @@ -0,0 +1,3 @@ +def pytest_sessionfinish( session, exitstatus ): + if exitstatus == 5: # pytest exit code for "no tests collected" + session.exitstatus = 0 diff --git a/tests/test_000_classcore/test_000_package.py b/tests/test_000_classcore/test_000_package.py index f3d5e3e..b29c3d0 100644 --- a/tests/test_000_classcore/test_000_package.py +++ b/tests/test_000_classcore/test_000_package.py @@ -23,46 +23,21 @@ import pytest -from . import ( - # MODULES_NAMES_BY_MODULE_QNAME, - MODULES_QNAMES, - PACKAGES_NAMES, - PACKAGES_NAMES_BY_MODULE_QNAME, - cache_import_module, -) +from . import __ -@pytest.mark.parametrize( 'package_name', PACKAGES_NAMES ) +@pytest.mark.parametrize( 'package_name', __.PACKAGES_NAMES ) def test_000_sanity( package_name ): ''' Package is sane. ''' - package = cache_import_module( package_name ) + package = __.cache_import_module( package_name ) assert package.__package__ == package_name assert package.__name__ == package_name -# @pytest.mark.parametrize( 'module_qname', MODULES_QNAMES ) -# def test_010_attribute_module_existence( module_qname ): -# ''' Package module is attribute of package. ''' -# package_name = PACKAGES_NAMES_BY_MODULE_QNAME[ module_qname ] -# package = cache_import_module( package_name ) -# module_name = MODULES_NAMES_BY_MODULE_QNAME[ module_qname ] -# assert module_name in package.__dict__ - - -# @pytest.mark.parametrize( 'module_qname', MODULES_QNAMES ) -# def test_011_attribute_module_classification( module_qname ): -# ''' Package attribute is module. ''' -# from inspect import ismodule -# package_name = PACKAGES_NAMES_BY_MODULE_QNAME[ module_qname ] -# package = cache_import_module( package_name ) -# module_name = MODULES_NAMES_BY_MODULE_QNAME[ module_qname ] -# assert ismodule( getattr( package, module_name ) ) - - -@pytest.mark.parametrize( 'module_qname', MODULES_QNAMES ) +@pytest.mark.parametrize( 'module_qname', __.MODULES_QNAMES ) def test_100_sanity( module_qname ): ''' Package module is sane. ''' - package_name = PACKAGES_NAMES_BY_MODULE_QNAME[ module_qname ] - module = cache_import_module( module_qname ) + package_name = __.PACKAGES_NAMES_BY_MODULE_QNAME[ module_qname ] + module = __.cache_import_module( module_qname ) assert module.__package__ == package_name assert module.__name__ == module_qname diff --git a/tests/test_000_classcore/test_010_base.py b/tests/test_000_classcore/test_010_base.py index e61dcee..343798d 100644 --- a/tests/test_000_classcore/test_010_base.py +++ b/tests/test_000_classcore/test_010_base.py @@ -23,7 +23,7 @@ import pytest -from . import PACKAGE_NAME, cache_import_module +from . import __ @pytest.mark.parametrize( @@ -31,5 +31,5 @@ ) def test_100_exports( module_name ): ''' Module exports expected names. ''' - module = cache_import_module( f"{PACKAGE_NAME}.__.imports" ) + module = __.cache_import_module( f"{__.PACKAGE_NAME}.__.imports" ) assert hasattr( module, module_name ) diff --git a/tests/test_000_classcore/test_100_exceptions.py b/tests/test_000_classcore/test_100_exceptions.py index 2ae3c03..4fc84ee 100644 --- a/tests/test_000_classcore/test_100_exceptions.py +++ b/tests/test_000_classcore/test_100_exceptions.py @@ -20,7 +20,7 @@ import pytest -from . import PACKAGE_NAME, cache_import_module +from .__ import PACKAGE_NAME, cache_import_module CLASS_NAMES = ( diff --git a/tests/test_000_classcore/test_110_utilities.py b/tests/test_000_classcore/test_110_utilities.py index 81c136a..bfcaa35 100644 --- a/tests/test_000_classcore/test_110_utilities.py +++ b/tests/test_000_classcore/test_110_utilities.py @@ -19,37 +19,17 @@ from dataclasses import dataclass -from platform import python_implementation import pytest -from . import PACKAGE_NAME, cache_import_module +from .__ import PACKAGE_NAME, cache_import_module MODULE_QNAME = f"{PACKAGE_NAME}.utilities" -pyimpl = python_implementation( ) - class Foo: x = 1 -class Bar( Foo ): pass - - -class Baz: - def __init__( self, value ): - self.x = value - - -class FooSlotsBase: __slots__ = ( 'x', ) - - -class FooSlots( FooSlotsBase ): - def __init__( self, value ): - self.x = value - - foo = Foo( ) -bar = Bar( ) def test_100_qualify_class_name( ): @@ -65,33 +45,32 @@ def test_110_describe_object( ): assert 'instance of class' in module.describe_object( foo ) -def test_200_getattr0_dict( ): - ''' Attribute from object dictionary without inheritance. ''' +def test_200_attr0( ): + ''' Can access and mutate special private attribute. ''' module = cache_import_module( MODULE_QNAME ) - function = module.getattr0 sentinel = object( ) - assert 1 == function( Foo, 'x', sentinel ) - assert sentinel == function( Bar, 'x', sentinel ) - assert sentinel == function( foo, 'x', sentinel ) - assert sentinel == function( bar, 'x', sentinel ) - baz = Baz( 42 ) - assert 42 == function( baz, 'x', sentinel ) - - -def test_205_getattr0_slots( ): - ''' Attribute from object slots (empty and filled). ''' - module = cache_import_module( MODULE_QNAME ) - function = module.getattr0 - sentinel = object( ) - foono = FooSlotsBase( ) - assert sentinel == function( foono, 'x', sentinel ) - foo = FooSlots( 42 ) - assert 42 == function( foo, 'x', sentinel ) - - -@pytest.mark.skipif( - 'CPython' != pyimpl, reason = 'Only relevant to CPython.' ) -def test_300_cpython_class_repair_function_closure( ): + class C: pass + module.setattr0( C, 'x', 1 ) + assert 1 == module.getattr0( C, 'x', sentinel ) + assert sentinel == module.getattr0( C, 'y', sentinel ) + class D( C ): pass + module.setattr0( D, 'y', 2 ) + assert sentinel == module.getattr0( D, 'x', sentinel ) + assert 2 == module.getattr0( D, 'y', sentinel ) + module.delattr0( C, 'x' ) + assert sentinel == module.getattr0( C, 'x', sentinel ) + class CS: __slots__ = ( 'z', ) + cs = CS( ) + assert sentinel == module.getattr0( cs, 'z', sentinel ) + module.setattr0( cs, 'z', 3 ) + assert 3 == module.getattr0( cs, 'z', sentinel ) + module.delattr0( cs, 'z' ) + assert sentinel == module.getattr0( cs, 'z', sentinel ) + with pytest.raises( AttributeError ): + module.delattr0( cs, 'missing' ) + + +def test_300_class_repair_function_closure( ): ''' Reproduction has class cell repaired in function closure. ''' class Wut: def __dir__( self ): return super( ).__dir__( ) @@ -104,9 +83,7 @@ def __dir__( self ): return super( ).__dir__( ) assert closure.cell_contents is DataclassWut -@pytest.mark.skipif( - 'CPython' != pyimpl, reason = 'Only relevant to CPython.' ) -def test_301_cpython_class_repair_property_closure( ): +def test_301_class_repair_property_closure( ): ''' Reproduction has class cell repaired in property closure. ''' class Wut: @property @@ -119,9 +96,7 @@ def name( self ): return super( ).__str__( ) assert wut.name == str( wut ) -@pytest.mark.skipif( - 'CPython' != pyimpl, reason = 'Only relevant to CPython.' ) -def test_302_cpython_class_repair_nothing( ): +def test_302_class_repair_nothing( ): ''' Reproduction has no class cell to repair in anything. ''' class Wut: @property @@ -136,3 +111,25 @@ def delname( self ): pass module.repair_class_reproduction( Wut, DataclassWut ) wut = DataclassWut( ) assert 'wut' == wut.name + + +def test_310_survey_mangled_names( ): + ''' Mangled-name survey detects digest-drifted variants. ''' + module = cache_import_module( MODULE_QNAME ) + class C: pass + base = '_classcore_class_in_progress_' + name_m = module.mangle_name( C, base ) + setattr( C, name_m, True ) + drifted = f"{base}{'a' * 64}" + setattr( C, drifted, False ) + setattr( C, f"{base}zz", False ) # suffix not hexadecimal + setattr( C, f"{base}{'a' * 63}", False ) # suffix short of digest + setattr( C, f"{base}{'a' * 65}", False ) # suffix beyond digest + names = module.survey_mangled_names( C, base ) + assert name_m in names + assert drifted in names + assert f"{base}zz" not in names + assert f"{base}{'a' * 63}" not in names + assert f"{base}{'a' * 65}" not in names + class D( C ): pass + assert ( ) == module.survey_mangled_names( D, base ) diff --git a/tests/test_000_classcore/test_320_standard_decorators.py b/tests/test_000_classcore/test_210_decorators.py similarity index 54% rename from tests/test_000_classcore/test_320_standard_decorators.py rename to tests/test_000_classcore/test_210_decorators.py index ad5c7aa..6101e30 100644 --- a/tests/test_000_classcore/test_320_standard_decorators.py +++ b/tests/test_000_classcore/test_210_decorators.py @@ -18,21 +18,32 @@ #============================================================================# -from . import PACKAGE_NAME, cache_import_module +# import pytest +from .__ import PACKAGE_NAME, cache_import_module -MODULE_QNAME = f"{PACKAGE_NAME}.standard.decorators" +MODULE_QNAME = f"{PACKAGE_NAME}.decorators" -def test_210_class_factory_decorator_idempotence( ): - ''' Class factory decorators are idempotent. ''' + +def test_111_produce_class_initialization_decorator_original( ): module = cache_import_module( MODULE_QNAME ) - @module.decoration_by( *module.class_factory_decorators ) - class Class: pass - @module.decoration_by( *module.class_factory_decorators ) - class BetterClass( Class ): pass - assert Class.__new__ is BetterClass.__new__ - assert Class.__init__ is BetterClass.__init__ - assert Class.__setattr__ is BetterClass.__setattr__ - assert Class.__delattr__ is BetterClass.__delattr__ - assert Class.__dir__ is BetterClass.__dir__ + base_module = cache_import_module( f"{PACKAGE_NAME}.__" ) + factories_module = cache_import_module( f"{PACKAGE_NAME}.factories" ) + constructor = factories_module.produce_class_constructor( + attributes_namer = base_module.calculate_attrname ) + cdecorator = module.produce_class_construction_decorator( + attributes_namer = base_module.calculate_attrname, + constructor = constructor ) + initializer = factories_module.produce_class_initializer( + attributes_namer = base_module.calculate_attrname ) + idecorator = module.produce_class_initialization_decorator( + attributes_namer = base_module.calculate_attrname, + initializer = initializer ) + @idecorator + @cdecorator + class Class( type ): + def __init__( self, *posargs, **nomargs ): + self._hello = 'Hi' + class Object( metaclass = Class ): pass + assert Object._hello == 'Hi' diff --git a/tests/test_000_classcore/test_310_standard_internals.py b/tests/test_000_classcore/test_310_standard_internals.py index 85427e6..9316fa4 100644 --- a/tests/test_000_classcore/test_310_standard_internals.py +++ b/tests/test_000_classcore/test_310_standard_internals.py @@ -20,7 +20,7 @@ import pytest -from . import PACKAGE_NAME, cache_import_module +from .__ import PACKAGE_NAME, cache_import_module MODULE_QNAME = f"{PACKAGE_NAME}.standard.__" @@ -33,3 +33,27 @@ def test_200_provide_error_class( ): assert exceptions.AttributeImmutability is error_class with pytest.raises( exceptions.ErrorProvideFailure ): module.provide_error_class( 'DoesNotExist' ) + + +def test_201_mutables_sets_public_from_nomina( ): + ''' Class-level mutability exemption sets are publicly re-exported + from 'standard.nomina' (canonical home remains 'standard.__'), + so downstream packages need not import across the private + module boundary. ''' + hub = cache_import_module( MODULE_QNAME ) + nomina = cache_import_module( f"{PACKAGE_NAME}.standard.nomina" ) + assert nomina.abc_class_mutables is hub.abc_class_mutables + assert nomina.typing_class_mutables is hub.typing_class_mutables + assert ( + '__typing_is_deprecated_inherited_runtime_protocol__' + in nomina.typing_class_mutables ) + + +def test_202_calculator_public_from_nomina( ): + ''' Reference calculator is publicly re-exported from + 'standard.nomina' (canonical home remains 'classcore.__'), + defaulting to the classcore package prefix. ''' + hub_nomina = cache_import_module( f"{PACKAGE_NAME}.__.nomina" ) + std_nomina = cache_import_module( f"{PACKAGE_NAME}.standard.nomina" ) + assert std_nomina.AttrnameCalculator is hub_nomina.AttrnameCalculator + assert 'classcore' == hub_nomina.AttrnameCalculator( ).package_name diff --git a/tests/test_000_classcore/test_315_standard_cascade.py b/tests/test_000_classcore/test_315_standard_cascade.py new file mode 100644 index 0000000..c684a61 --- /dev/null +++ b/tests/test_000_classcore/test_315_standard_cascade.py @@ -0,0 +1,421 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +''' Characterizing tests for the attribute behavior decision cascade. + + These tests pin the current behavior of the cores in + standard/behaviors.py — active-behavior sets, exclusion names, + wildcard, predicates, regexes, and the survey matching loops — + before the cascade is extracted into pure helpers shared with the + explanations module (OpenSpec add-attribute-explanations, task 1.1). +''' + + +import re + +import pytest + +from .__ import PACKAGE_NAME, cache_import_module + + +MODULE_QNAME = f"{PACKAGE_NAME}.standard.behaviors" +DECORATORS_QNAME = f"{PACKAGE_NAME}.standard.decorators" +EXCEPTIONS_QNAME = f"{PACKAGE_NAME}.exceptions" + + +def _predicate_matches_alpha( name: str ) -> bool: + return 'alpha' == name + + +class _OpaquePredicate: + ''' Callable predicate whose introspectable metadata raises. + + Simulates exotic callables (raising descriptors, custom + '__getattribute__'): boolean invocation works, but any + diagnostic rendering metadata access fails. + ''' + + def __call__( self, name: str ) -> bool: + return 'alpha' == name + + def __getattribute__( self, name: str ): + if name in ( 'pattern', '__qualname__', '__module__' ): + raise RuntimeError( 'opaque' ) + return object.__getattribute__( self, name ) + + +def test_300_immutable_without_exclusions( ): + ''' Immutability active and no rule permits: assignment raises. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + exceptions = cache_import_module( EXCEPTIONS_QNAME ) + + @decorators.with_standard_behaviors( ) + class Example: pass + + instance = Example( ) + with pytest.raises( exceptions.AttributeImmutability ): + instance.alpha = 1 + with pytest.raises( exceptions.AttributeImmutability ): + del instance.alpha + + +def test_301_immutable_excluded_by_name( ): + ''' Name exclusion permits assignment and deletion. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( mutables = ( 'alpha', ) ) + class Example: pass + + instance = Example( ) + instance.alpha = 1 + assert 1 == instance.alpha + del instance.alpha + assert not hasattr( instance, 'alpha' ) + + +def test_302_immutable_excluded_by_regex( ): + ''' Regex exclusion permits assignment. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( + mutables = ( re.compile( 'al.*' ), ) ) + class Example: pass + + instance = Example( ) + instance.alpha = 1 + assert 1 == instance.alpha + + +def test_303_immutable_excluded_by_predicate( ): + ''' Predicate exclusion permits assignment. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( + mutables = ( _predicate_matches_alpha, ) ) + class Example: pass + + instance = Example( ) + instance.alpha = 1 + assert 1 == instance.alpha + + +def test_304_immutable_wildcard( ): + ''' Wildcard exclusion permits any assignment. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( mutables = '*' ) + class Example: pass + + instance = Example( ) + instance.anything = 1 + assert 1 == instance.anything + + +def test_310_concealment_without_visibles( ): + ''' Concealment hides non-public names; public names stay visible + via the default public-identifier predicate. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( ) + class Example: + _private = 1 + public = 2 + + instance = Example( ) + assert '_private' not in dir( instance ) + assert 'public' in dir( instance ) + + +def test_310a_concealment_no_visibles_rules( ): + ''' Concealment with empty visibles hides every name. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( visibles = ( ) ) + class Example: + alpha = 1 + + instance = Example( ) + assert 'alpha' not in dir( instance ) + + +def test_311_concealment_visibles_by_name( ): + ''' Visibles name rule reveals the name in dir. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( visibles = ( 'alpha', ) ) + class Example: + alpha = 1 + + instance = Example( ) + assert 'alpha' in dir( instance ) + + +def test_312_concealment_visibles_wildcard( ): + ''' Visibles wildcard reveals all names. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( visibles = '*' ) + class Example: + alpha = 1 + + instance = Example( ) + assert 'alpha' in dir( instance ) + + +def test_313_concealment_visibles_by_regex( ): + ''' Visibles regex rule reveals matching names. ''' + decorators = cache_import_module( DECORATORS_QNAME ) + + @decorators.with_standard_behaviors( + visibles = ( re.compile( 'al.*' ), ) ) + class Example: + alpha = 1 + beta = 2 + + instance = Example( ) + assert 'alpha' in dir( instance ) + assert 'beta' not in dir( instance ) + + +def test_314_survey_multi_match_yields_duplicates( ): + ''' Survey yields a name multiple times when multiple rules match. + + Characterizes the current control flow: the per-name matching + loops continue the inner loop only, so a name matching one + predicate and one regex is appended twice. Preserved deliberately + by the extraction; normalization is a separate repair. + + Note the lookup asymmetry: the core reads the active-behaviors + set through the mangling accessors, but exclusion configuration + through plain attribute access under raw level names. + ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + utilities = cache_import_module( f"{PACKAGE_NAME}.utilities" ) + + class Example: pass + example = Example( ) + utilities.setattr0( + example, base.calculate_attrname( 'instance', 'behaviors' ), + frozenset( ( 'concealment', ) ) ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_names' ), + frozenset( ) ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_predicates' ), + ( _predicate_matches_alpha, ) ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_regexes' ), + ( re.compile( 'al.*' ), ) ) + + visible = list( module.survey_visible_attributes( + example, ligation = lambda: [ 'alpha', 'beta' ], + attributes_namer = base.calculate_attrname, + level = 'instances' ) ) + assert [ 'alpha', 'alpha' ] == visible + + +def test_315_survey_wildcard_short_circuits( ): + ''' Survey wildcard returns all names before rule evaluation. ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + utilities = cache_import_module( f"{PACKAGE_NAME}.utilities" ) + + calls: list[ str ] = [ ] + + def recording_predicate( name: str ) -> bool: + calls.append( name ) + return True + + class Example: pass + example = Example( ) + utilities.setattr0( + example, base.calculate_attrname( 'instance', 'behaviors' ), + frozenset( ( 'concealment', ) ) ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_names' ), '*' ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_predicates' ), + ( recording_predicate, ) ) + + visible = list( module.survey_visible_attributes( + example, ligation = lambda: [ 'alpha' ], + attributes_namer = base.calculate_attrname, + level = 'instances' ) ) + assert [ 'alpha' ] == visible + assert [ ] == calls + + +def test_316_survey_names_match_skips_rules( ): + ''' A names match short-circuits predicate and regex evaluation. + + Characterizes the original control flow, where a names match + continued the outer per-name loop: with a name rule overlapping + predicate and regex rules, the name is yielded once and no + predicate or regex runs for it. + ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + utilities = cache_import_module( f"{PACKAGE_NAME}.utilities" ) + + calls: list[ str ] = [ ] + + def recording_predicate( name: str ) -> bool: + calls.append( name ) + return True + + class Example: pass + example = Example( ) + utilities.setattr0( + example, base.calculate_attrname( 'instance', 'behaviors' ), + frozenset( ( 'concealment', ) ) ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_names' ), + frozenset( ( 'alpha', ) ) ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_predicates' ), + ( recording_predicate, ) ) + setattr( example, + base.calculate_attrname( 'instances', 'visibles_regexes' ), + ( re.compile( 'al.*' ), ) ) + + visible = list( module.survey_visible_attributes( + example, ligation = lambda: [ 'alpha' ], + attributes_namer = base.calculate_attrname, + level = 'instances' ) ) + assert [ 'alpha' ] == visible + assert [ ] == calls + + +def test_320_render_verifier_text( ): + ''' Verifier rendering: pattern text, qualified names, fallbacks. ''' + module = cache_import_module( MODULE_QNAME ) + + assert 'al.*' == module.render_verifier_text( re.compile( 'al.*' ) ) + assert ( + f"{__name__}._predicate_matches_alpha" + == module.render_verifier_text( _predicate_matches_alpha ) ) + assert '' == module.render_verifier_text( object( ) ) + + class _Patterned: + pattern = 42 + + assert ( + 'test_320_render_verifier_text.._Patterned' + == module.render_verifier_text( _Patterned ) ) + + class _NonStrQualname: + def __getattribute__( self, name: str ): + if '__qualname__' == name: return 42 + return object.__getattribute__( self, name ) + + assert '' == module.render_verifier_text( + _NonStrQualname( ) ) + + class _NonStrModule: + def __getattribute__( self, name: str ): + if '__qualname__' == name: return 'weird.Qual' + if '__module__' == name: return 42 + return object.__getattribute__( self, name ) + + assert 'weird.Qual' == module.render_verifier_text( + _NonStrModule( ) ) + + def local_predicate( name: str ) -> bool: return True + + assert ( + 'test_320_render_verifier_text..local_predicate' + == module.render_verifier_text( local_predicate ) ) + + +def test_321_first_permitting_rule_precedence( ): + ''' First permitting rule follows wildcard/names/predicate/regex + precedence and reports kind with raw verifier. ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + namer = base.calculate_attrname + + class Stub: pass + stub = Stub( ) + + setattr( stub, namer( 'instances', 'mutables_names' ), '*' ) + assert ( 'omni', '*' ) == module.provide_permitter_if_applicable( + stub, attributes_namer = namer, level = 'instances', + basename = 'mutables', name = 'anything' ) + + setattr( stub, namer( 'instances', 'mutables_names' ), + frozenset( ( 'alpha', ) ) ) + assert ( 'names', 'alpha' ) == module.provide_permitter_if_applicable( + stub, attributes_namer = namer, level = 'instances', + basename = 'mutables', name = 'alpha' ) + + setattr( stub, namer( 'instances', 'mutables_names' ), frozenset( ) ) + setattr( stub, namer( 'instances', 'mutables_predicates' ), + ( _predicate_matches_alpha, ) ) + setattr( stub, namer( 'instances', 'mutables_regexes' ), + ( re.compile( 'al.*' ), ) ) + assert ( 'predicate', _predicate_matches_alpha + ) == module.provide_permitter_if_applicable( + stub, attributes_namer = namer, level = 'instances', + basename = 'mutables', name = 'alpha' ) + + setattr( stub, namer( 'instances', 'mutables_predicates' ), ( ) ) + assert ( 'regex', re.compile( 'al.*' ) + ) == module.provide_permitter_if_applicable( + stub, attributes_namer = namer, level = 'instances', + basename = 'mutables', name = 'alpha' ) + + setattr( stub, namer( 'instances', 'mutables_regexes' ), ( ) ) + assert None is module.provide_permitter_if_applicable( + stub, attributes_namer = namer, level = 'instances', + basename = 'mutables', name = 'alpha' ) + + +def test_322_matched_rules_union( ): + ''' Matched rules report predicate/regex matches in evaluation order; + a names match short-circuits to the single names rule. Rules + carry raw verifiers; rendering happens at the record layer. ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + namer = base.calculate_attrname + + class Stub: pass + stub = Stub( ) + setattr( stub, namer( 'instances', 'visibles_names' ), + frozenset( ( 'alpha', ) ) ) + assert ( ( 'names', 'alpha' ), ) == module.determine_relevant_permitters( + stub, attributes_namer = namer, level = 'instances', + basename = 'visibles', name = 'alpha' ) + + setattr( stub, namer( 'instances', 'visibles_names' ), frozenset( ) ) + setattr( stub, namer( 'instances', 'visibles_predicates' ), + ( _predicate_matches_alpha, ) ) + setattr( stub, namer( 'instances', 'visibles_regexes' ), + ( re.compile( 'al.*' ), ) ) + + assert ( + ( 'predicate', _predicate_matches_alpha ), + ( 'regex', re.compile( 'al.*' ) ) + ) == module.determine_relevant_permitters( + stub, attributes_namer = namer, level = 'instances', + basename = 'visibles', name = 'alpha' ) + assert ( ) == module.determine_relevant_permitters( + stub, attributes_namer = namer, level = 'instances', + basename = 'visibles', name = 'beta' ) diff --git a/tests/test_000_classcore/test_320_standard_explanations.py b/tests/test_000_classcore/test_320_standard_explanations.py new file mode 100644 index 0000000..7c3725f --- /dev/null +++ b/tests/test_000_classcore/test_320_standard_explanations.py @@ -0,0 +1,755 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +''' Tests for attribute explanations (OpenSpec add-attribute-explanations). ''' + +import re + +import pytest + +from .__ import PACKAGE_NAME, cache_import_module + + +MODULE_QNAME = f"{PACKAGE_NAME}.standard.explanations" +DECORATORS_QNAME = f"{PACKAGE_NAME}.standard.decorators" +EXCEPTIONS_QNAME = f"{PACKAGE_NAME}.exceptions" + + +def _matches_alpha( name: str ) -> bool: + return 'alpha' == name + + +class _OpaquePredicate: + ''' Callable predicate whose introspectable metadata raises. + + Simulates exotic callables (raising descriptors, custom + '__getattribute__'): boolean invocation works, but any + diagnostic rendering metadata access fails. Enforcement and + survey must follow the boolean outcome regardless. + ''' + + def __call__( self, name: str ) -> bool: + return True + + def __getattribute__( self, name: str ): + if name in ( 'pattern', '__qualname__', '__module__' ): + raise RuntimeError( 'opaque' ) + return object.__getattribute__( self, name ) + + +def _produce_example( **verifiers ): + decorators = cache_import_module( DECORATORS_QNAME ) + + return decorators.with_standard_behaviors( **verifiers ) + + +def test_400_explain_inapplicable_behavior( ): + ''' Inactive behavior: permitted by inapplicability. ''' + module = cache_import_module( MODULE_QNAME ) + + class Plain: pass + + explanation = module.explain_attribute( Plain( ), 'anything' ) + assign = explanation.operations[ 'assign' ] + survey = explanation.operations[ 'survey' ] + assert isinstance( assign.decision, module.PermitByInapplicability ) + assert assign.permissible + assert isinstance( survey.decision, module.PermitByInapplicability ) + assert survey.permissible + + +def test_401_explain_permit_by_names( ): + ''' Names exclusion: permitted by names carrying the matched name. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( mutables = ( 'alpha', ) ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + assign = explanation.operations[ 'assign' ] + assert isinstance( assign.decision, module.PermitByNames ) + assert 'alpha' == assign.decision.name + assert assign.permissible + + +def test_402_explain_permit_by_regex( ): + ''' Regex exclusion: permitted by regex carrying pattern text. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( mutables = ( re.compile( 'al.*' ), ) ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + assign = explanation.operations[ 'assign' ] + assert isinstance( assign.decision, module.PermitByRegex ) + assert 'al.*' == assign.decision.pattern + + +def test_403_explain_permit_by_predicate( ): + ''' Predicate exclusion: permitted by predicate carrying the + qualified name text. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( mutables = ( _matches_alpha, ) ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + assign = explanation.operations[ 'assign' ] + assert isinstance( assign.decision, module.PermitByPredicate ) + assert f"{__name__}._matches_alpha" == assign.decision.predicate + + +def test_404_explain_permit_by_omni( ): + ''' Omni exclusion for assign/delete: permitted by omni. + + An omni marker never coexists with an active behavior through + the recording API (record_behavior skips the behavior label for + omni verifiers), so this constructs the defensive state the + cores are specified to handle: immutability active with the + exclusion names configured as the omni marker. + ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Example( classes.Object ): pass + + type.__setattr__( + Example, base.calculate_attrname( 'class', 'mutables_names' ), '*' ) + explanation = module.explain_attribute( Example, 'anything' ) + assign = explanation.operations[ 'assign' ] + delete = explanation.operations[ 'delete' ] + assert isinstance( assign.decision, module.PermitByOmni ) + assert assign.permissible + assert isinstance( delete.decision, module.PermitByOmni ) + + +def test_405_explain_prohibit( ): + ''' Active behavior without permitting rule: prohibited. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + assign = explanation.operations[ 'assign' ] + assert isinstance( assign.decision, module.Prohibit ) + assert not assign.permissible + + +def test_406_explain_concealed_attribute( ): + ''' Concealment without visibles rule: survey prohibited. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( visibles = ( ) ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + survey = explanation.operations[ 'survey' ] + assert isinstance( survey.decision, module.Prohibit ) + assert not survey.permissible + + +def test_407_explain_survey_precedence( ): + ''' Survey follows precedence: the first matching rule decides. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( + visibles = ( _matches_alpha, re.compile( 'al.*' ) ) ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + survey = explanation.operations[ 'survey' ] + assert isinstance( survey.decision, module.PermitByPredicate ) + assert not isinstance( survey.decision, module.PermitByRegex ) + + +def test_407a_explain_survey_names_precedence( ): + ''' Survey names match short-circuits predicate and regex rules. ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + calls: list[ str ] = [ ] + + def recording_predicate( name: str ) -> bool: + calls.append( name ) + return True + + class Example( classes.Object ): pass + + type.__setattr__( + Example, base.calculate_attrname( 'class', 'visibles_names' ), + frozenset( ( 'alpha', ) ) ) + type.__setattr__( + Example, base.calculate_attrname( 'class', 'visibles_predicates' ), + ( recording_predicate, ) ) + explanation = module.explain_attribute( Example, 'alpha' ) + survey = explanation.operations[ 'survey' ] + assert isinstance( survey.decision, module.PermitByNames ) + assert [ ] == calls + + +def test_408_explain_survey_omni( ): + ''' Survey omni: permitted by omni, no predicate/regex evaluation. ''' + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + calls: list[ str ] = [ ] + + def recording_predicate( name: str ) -> bool: + calls.append( name ) + return True + + class Example( classes.Object ): pass + + type.__setattr__( + Example, base.calculate_attrname( 'class', 'visibles_names' ), '*' ) + type.__setattr__( + Example, base.calculate_attrname( 'class', 'visibles_predicates' ), + ( recording_predicate, ) ) + explanation = module.explain_attribute( Example, 'anything' ) + survey = explanation.operations[ 'survey' ] + assert isinstance( survey.decision, module.PermitByOmni ) + assert [ ] == calls + + +def test_410_explanation_records_frozen( ): + ''' Assigning a record field raises immutability error. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions = cache_import_module( EXCEPTIONS_QNAME ) + decorate = _produce_example( mutables = ( 'alpha', ) ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + with pytest.raises( exceptions.AttributeImmutability ): + explanation.name = 'other' + with pytest.raises( exceptions.AttributeImmutability ): + explanation.operations[ 'assign' ].decision = module.Prohibit( ) + with pytest.raises( exceptions.AttributeImmutability ): + explanation.operations[ 'assign' ].decision.name = 'other' + + +def test_411_explanation_collections_immutable( ): + ''' Mutating nested collections raises. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + with pytest.raises( TypeError ): + explanation.operations[ 'survey' ] = None + behaviors = explanation.behaviors + with pytest.raises( TypeError ): + behaviors[ 'new' ] = frozenset( ) + + +def test_412_explanation_internal_marking( ): + ''' Framework names mark via the namer detector; stdlib machinery + names mark only where the machinery operates. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( ) + + @decorate + class Example: pass + + import hashlib + digest = hashlib.sha256( b'x' ).hexdigest( ) + framework = module.explain_attribute( + Example( ), f"_classcore_class_in_progress_{digest}" ) + machinery_absent = module.explain_attribute( Example( ), '_abc_cache' ) + user = module.explain_attribute( Example( ), 'alpha' ) + assert framework.internal + assert not machinery_absent.internal # no ABC machinery here + assert not user.internal + + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Abstract( classes.AbstractObject ): pass + + machinery_present = module.explain_attribute( + Abstract( ), '_abc_cache' ) + assert machinery_present.internal + assert not module.explain_attribute( + Abstract( ), 'alpha' ).internal + + +def test_413_explanation_level_semantics( ): + ''' Class targets evaluate classes level; instances evaluate + instances level. ''' + module = cache_import_module( MODULE_QNAME ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + explanation = module.explain_attribute( classes.Object, 'alpha' ) + assert 'class' in explanation.behaviors + instance_explanation = ( + module.explain_attribute( classes.Object( ), 'alpha' ) ) + assert 'instance' in instance_explanation.behaviors + + +def test_414_explanation_observational( ): + ''' Explanations do not bypass or alter behaviors. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions = cache_import_module( EXCEPTIONS_QNAME ) + decorate = _produce_example( ) + + @decorate + class Example: pass + + instance = Example( ) + module.explain_attribute( instance, 'alpha' ) + with pytest.raises( exceptions.AttributeImmutability ): + instance.alpha = 1 + + +def test_415_explanation_repr_summary( ): + ''' Repr summarizes target, behaviors, and decision outcomes. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( + mutables = ( 'alpha', ), visibles = ( re.compile( 'pub.*' ), ) ) + + @decorate + class Example: + alpha = 1 + public_total = 2 + + permitted = repr( module.explain_attribute( Example( ), 'alpha' ) ) + assert "'alpha' on instance of class" in permitted + assert 'behaviors: concealment, immutability (instance)' in permitted + assert "assign: permitted by names 'alpha'" in permitted + assert "delete: permitted by names 'alpha'" in permitted + + visible = repr( module.explain_attribute( Example( ), 'public_total' ) ) + assert "survey: permitted by regex 'pub.*'" in visible + assert 'assign: prohibited (no permitting rule)' in visible + + class Plain: pass + + plain = repr( module.explain_attribute( Plain( ), 'x' ) ) + assert 'permitted (behavior inapplicable)' in plain + assert '[internal]' not in plain + + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Abstract( classes.AbstractObject ): pass + + internal = repr( + module.explain_attribute( Abstract( ), '_abc_cache' ) ) + assert '[internal]' in internal + + +def test_416_names_payload_collision( ): + ''' A name literally 'regex' permitted by names stays distinct from + a regex decision. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( mutables = ( 'regex', ) ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'regex' ) + decision = explanation.operations[ 'assign' ].decision + assert isinstance( decision, module.PermitByNames ) + assert 'regex' == decision.name + assert not isinstance( decision, module.PermitByRegex ) + + +def test_417_permissible_derivation( ): + ''' Permissibility derives from the decision type. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( mutables = ( 'alpha', ) ) + + @decorate + class Example: pass + + permitted = module.explain_attribute( Example( ), 'alpha' ) + prohibited = module.explain_attribute( Example( ), 'beta' ) + assert permitted.operations[ 'assign' ].permissible + assert not prohibited.operations[ 'assign' ].permissible + assert prohibited.operations[ 'assign' ].permissible is ( + not isinstance( + prohibited.operations[ 'assign' ].decision, module.Prohibit ) ) + + +def test_418_render_covers_decisions( ): + """ Repr renders every decision branch. """ + module = cache_import_module( MODULE_QNAME ) + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Example( classes.Object ): pass + + type.__setattr__( + Example, base.calculate_attrname( 'class', 'visibles_names' ), '*' ) + type.__setattr__( + Example, base.calculate_attrname( 'class', 'mutables_predicates' ), + ( _matches_alpha, ) ) + omni = repr( module.explain_attribute( Example, 'anything' ) ) + assert "survey: permitted by omni '*'" in omni + predicated = repr( module.explain_attribute( Example, 'alpha' ) ) + assert ( + f"assign: permitted by predicate '{__name__}._matches_alpha'" + in predicated ) + + +def test_419_produce_decision_rejects_unknown_kinds( ): + ''' Unknown rule kinds raise instead of misclassifying. ''' + module = cache_import_module( MODULE_QNAME ) + + with pytest.raises( ValueError, match = 'unknown' ): + module.produce_decision( ( 'unknown', 'x' ), True ) + inapplicable = module.produce_decision( None, False ) + assert isinstance( inapplicable, module.PermitByInapplicability ) + prohibited = module.produce_decision( None, True ) + assert isinstance( prohibited, module.Prohibit ) + omni = module.produce_decision( ( 'omni', '*' ), True ) + assert isinstance( omni, module.PermitByOmni ) + + +def test_420_render_rejects_unknown_decisions( ): + ''' Rendering an unknown decision raises instead of reporting + prohibited. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( ) + + @decorate + class Example: pass + + explanation = module.explain_attribute( Example( ), 'alpha' ) + with pytest.raises( ValueError, match = 'object' ): + explanation._render( object( ) ) + + +def test_421_grammar_static_forms_marked( ): + ''' All grammar-valid static stems mark; invalid cores do not. ''' + module = cache_import_module( MODULE_QNAME ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Example( classes.Object ): pass + + for level in ( 'class', 'classes', 'instance', 'instances' ): + for core in ( 'mutables_names', 'visibles_predicates', + 'assigner_core', 'construction_arguments' ): + name = f"_classcore_{level}_{core}_" + assert module.explain_attribute( + Example, name ).internal, name + for name in ( '_classcore_not_a_generated_core_', + '_classcore_class_lookalike_', '_classcore_lookalike_behaviors_', + '_classcore_zzz_abc' ): + assert not module.explain_attribute( Example, name ).internal + + +def test_422_grammar_mangled_forms( ): + ''' Both mangled forms mark with valid digests only. ''' + import hashlib + module = cache_import_module( MODULE_QNAME ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Example( classes.Object ): pass + + digest = hashlib.sha256( b'probe' ).hexdigest( ) + for stem in ( 'class_behaviors', 'class_in_progress' ): + assert module.explain_attribute( + Example, f"_classcore_{stem}_{digest}" ).internal + for name in ( + f"_classcore_class_in_progress_{digest[ :-1 ]}", + f"_classcore_class_in_progress_{digest.upper( )}", + f"_classcore_class_in_progress_{digest}_", + '_classcore_class_in_progress_', + '_classcore_class_behaviors_', + ): + assert not module.explain_attribute( Example, name ).internal + + +def test_423_instance_target_metaclass_chain( ): + ''' Instance targets consult the metaclass chain via the double + type transformation. ''' + module = cache_import_module( MODULE_QNAME ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Example( classes.Object ): pass + + instance = Example( ) + assert module.explain_attribute( + instance, '_classcore_class_mutables_names_' ).internal + # The class MRO of the instance's type contains no metaclass; + # marking proves the metaclass chain was consulted. + assert not any( + holder is type( Example ) + for holder in type( instance ).__mro__ ) + + +def test_424_inherited_contribution_across_packages( ): + ''' A class defined in the test module (a foreign module, from the + framework's perspective) inherits the base's contributions + through the base's metaclass. ''' + module = cache_import_module( MODULE_QNAME ) + classes = cache_import_module( f"{PACKAGE_NAME}.standard.classes" ) + + class Derived( classes.Object ): pass + + assert module.explain_attribute( + Derived, '_classcore_instances_mutables_names_' ).internal + + +def test_425_namer_vocabulary_consistency( ): + ''' Every literal namer call site in the package sources has its + level and core covered by the detector grammar, and at least + one site is found (guarding against a vacuous scan). ''' + import ast + from pathlib import Path + base = cache_import_module( f"{PACKAGE_NAME}.__" ) + det = base.calculate_attrname.is_internal_name + namer_names = ( 'calculate_attrname', 'attributes_namer' ) + levels = ( 'class', 'classes', 'instance', 'instances' ) + sites: list[ tuple[ str, str ] ] = [ ] + for path in Path( 'sources' ).rglob( '*.py' ): + tree = ast.parse( path.read_text( ) ) + for node in ast.walk( tree ): + if not ( + isinstance( node, ast.Call ) + and isinstance( node.func, ast.Name ) + and node.func.id in namer_names + and 2 == len( node.args ) + and all( + isinstance( a, ast.Constant ) + and isinstance( a.value, str ) + for a in node.args ) ): + continue + level, core = ( + node.args[ 0 ].value, node.args[ 1 ].value ) + sites.append( ( level, core ) ) + assert level in levels, ( path, level ) + marked = ( + det( f"_classcore_{level}_{core}_" ) + or ( 'class' == level and det( + f"_classcore_class_{core}_" + 'a' * 64 ) ) ) + assert marked, ( path, level, core ) + assert 5 <= len( sites ) + + +def test_426_bare_namer_contributes_nothing( ): + ''' A namer without an is_internal_name attribute contributes + nothing, and downstream detectors ride only when provided. ''' + from classcore.standard.decorators import class_factory + module = cache_import_module( MODULE_QNAME ) + + def bare_namer( level: str, core: str ) -> str: + return f"_custom_{level}_{core}_" + + @class_factory( attributes_namer = bare_namer ) + class CustomMeta( type ): pass + + class Custom( metaclass = CustomMeta ): pass + + assert not module.explain_attribute( + Custom, '_classcore_class_mutables_names_' ).internal + assert not module.explain_attribute( + Custom, '_custom_class_core_' ).internal + + @class_factory( attributes_namer = bare_namer ) + class BareMeta( type ): pass + + class Bare( metaclass = BareMeta ): pass + + assert not module.explain_attribute( + Bare, '_custom_class_core_' ).internal + + from classcore.standard.nomina import AttrnameCalculator + + namer = AttrnameCalculator( package_name = 'downstream' ) + + @class_factory( attributes_namer = namer ) + class DownstreamMeta( type ): pass + + class Downstream( metaclass = DownstreamMeta ): pass + + assert module.explain_attribute( + Downstream, '_downstream_instances_mutables_names_' ).internal + assert not module.explain_attribute( + Downstream, '_downstream_custom_' ).internal + assert not module.explain_attribute( + Downstream, '_classcore_instances_mutables_names_' ).internal + + +def test_427_decorator_path_inheritance( ): + ''' Subclassing a decorated class keeps internal marking via the + class resolution order. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( ) + + @decorate + class Base: pass + + class Derived( Base ): pass + + assert module.explain_attribute( + Base, '_classcore_instances_mutables_names_' ).internal + assert module.explain_attribute( + Derived, '_classcore_instances_mutables_names_' ).internal + + +def test_428_augment_internal_names_idempotent( ): + ''' Private augmentation wiring dedupes repeated bound detectors. + + The bound method retrieved twice from one namer instance is + one detector: fresh bindings of the same instance's method must + dedupe, which plain identity comparison would miss. + ''' + from classcore.__ import AttrnameCalculator, calculate_contribution_name + from classcore.standard.__ import augment_internal_names + + namer = AttrnameCalculator( ) + + class Meta( type ): pass + + augment_internal_names( + Meta, namer.is_internal_name ) + augment_internal_names( + Meta, namer.is_internal_name ) # Fresh bound method. + assert 1 == len( getattr( Meta, calculate_contribution_name( ) ) ) + + +def test_429_distinct_instances_keep_separate_contributions( ): + ''' Two namer instances sharing method implementation but with + instance-configured detector behavior keep both contributions. ''' + from classcore.__ import AttrnameCalculator, calculate_contribution_name + from classcore.standard.decorators import class_factory + module = cache_import_module( MODULE_QNAME ) + + class ConfiguredNamer( AttrnameCalculator ): + def __init__( self, prefix: str ): + self.prefix = prefix + def __call__( self, level: str, core: str ) -> str: + return f"{self.prefix}_{level}_{core}_" + def is_internal_name( self, name: str ) -> bool: + return name.startswith( self.prefix ) + + first = ConfiguredNamer( '_first_' ) + second = ConfiguredNamer( '_second_' ) + + @class_factory( attributes_namer = first ) + class FirstMeta( type ): pass + + @class_factory( attributes_namer = second ) + class SecondMeta( FirstMeta ): pass + + class Product( metaclass = SecondMeta ): pass + + assert module.explain_attribute( + Product, '_first_class_core_' ).internal + assert module.explain_attribute( + Product, '_second_class_core_' ).internal + contributions = getattr( Product, calculate_contribution_name( ) ) + assert 2 == len( contributions ) + + +def test_430_plain_function_detectors_by_identity( ): + ''' Plain function detectors compare by identity, not attribute + probing: a non-method callable without __func__ dedupes only + against itself. ''' + from classcore.__ import calculate_contribution_name + from classcore.standard.__ import augment_internal_names + + def detector_a( name: str ) -> bool: + return name.startswith( '_a_' ) + + def detector_b( name: str ) -> bool: + return name.startswith( '_b_' ) + + class Meta( type ): pass + + augment_internal_names( Meta, detector_a ) + augment_internal_names( Meta, detector_a ) + augment_internal_names( Meta, detector_b ) + assert 2 == len( getattr( Meta, calculate_contribution_name( ) ) ) + + +def test_431_opaque_predicate_governs_by_bool( ): + ''' Opaque predicate: enforcement and survey follow the boolean + outcome despite hostile introspection metadata; the + explanation record renders the documented fallback. ''' + module = cache_import_module( MODULE_QNAME ) + decorate = _produce_example( + mutables = ( _OpaquePredicate( ), ), + visibles = ( _OpaquePredicate( ), ) ) + + @decorate + class Example: pass + + instance = Example( ) + instance.alpha = 1 + assert 1 == instance.alpha + assert 'alpha' in dir( instance ) + explanation = module.explain_attribute( instance, 'alpha' ) + assign = explanation.operations[ 'assign' ] + assert isinstance( assign.decision, module.PermitByPredicate ) + assert '' == assign.decision.predicate + assert assign.permissible + survey = explanation.operations[ 'survey' ] + assert isinstance( survey.decision, module.PermitByPredicate ) + assert '' == survey.decision.predicate + assert survey.permissible + del instance.alpha + + +def test_432_calculator_prefix_mismatch( ): + ''' Configured prefix governs both generation and detection: + cross-prefix names do not match, mangled digest forms are + accepted, and bare mangling stems are not. ''' + from classcore.standard.nomina import AttrnameCalculator + + frigid = AttrnameCalculator( package_name = 'frigid' ) + assert '_frigid_class_behaviors_' == frigid( 'class', 'behaviors' ) + assert frigid.is_internal_name( + '_frigid_instances_mutables_names_' ) + assert frigid.is_internal_name( + '_frigid_class_behaviors_' + 'b' * 64 ) + assert not frigid.is_internal_name( '_frigid_class_behaviors_' ) + assert not frigid.is_internal_name( + '_classcore_instances_mutables_names_' ) + assert not frigid.is_internal_name( '_frigid_custom_' ) + + +def test_433_calculator_prefix_read_only( ): + ''' Configured prefix is read-only: post-construction assignment + raises, and generation and detection retain the original. ''' + from classcore.standard.nomina import AttrnameCalculator + + namer = AttrnameCalculator( package_name = 'frigid' ) + assert 'frigid' == namer.package_name + with pytest.raises( AttributeError ): + # Assignment, rather than direct attribute syntax, so the + # read-only property violation is exercised, not type-checked. + setattr( namer, 'package_name', 'classcore' ) + assert '_frigid_class_behaviors_' == namer( 'class', 'behaviors' ) + assert namer.is_internal_name( + '_frigid_instances_mutables_names_' ) diff --git a/tests/test_000_classcore/test_330_standard_decorators.py b/tests/test_000_classcore/test_330_standard_decorators.py new file mode 100644 index 0000000..c1378e9 --- /dev/null +++ b/tests/test_000_classcore/test_330_standard_decorators.py @@ -0,0 +1,186 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +import pytest + +from .__ import PACKAGE_NAME, cache_import_module + + +MODULE_QNAME = f"{PACKAGE_NAME}.standard.decorators" + + +def simple_assigner_core( # noqa: PLR0917 + objct, + ligation, + attributes_namer, + error_class_provider, + level, name, value +): + object.__setattr__( objct, name, value ) + + +def simple_deleter_core( # noqa: PLR0917 + objct, + ligation, + attributes_namer, + error_class_provider, + level, name +): + object.__delattr__( objct, name ) + + +def simple_surveyor_core( objct, ligation, attributes_namer, level ): + return object.__dir__( objct ) + + +def class_simple_assigner_core( # noqa: PLR0917 + cls, + ligation, + attributes_namer, + error_class_provider, + level, name, value +): + type.__setattr__( cls, name, value ) + + +def class_simple_deleter_core( # noqa: PLR0917 + cls, + ligation, + attributes_namer, + error_class_provider, + level, name +): + type.__delattr__( cls, name ) + + +def class_simple_surveyor_core( cls, ligation, attributes_namer, level ): + return type.__dir__( cls ) + + +def test_120_decorator_core_function_inheritance( ): + ''' Core functions (via decorator) inherited. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + + @module.with_standard_behaviors( ) + class Base: pass + + b = Base( ) + with pytest.raises( exceptions.AttributeImmutability ): + b.bar = 6 + assert not hasattr( b, 'bar' ) + assert 'bar' not in dir( b ) + with pytest.raises( exceptions.AttributeImmutability ): + del b.bar + + @module.with_standard_behaviors( ) + class Derivation( Base ): pass + + d = Derivation( ) + with pytest.raises( exceptions.AttributeImmutability ): + d.foo = 2 + assert not hasattr( d, 'foo' ) + assert 'foo' not in dir( d ) + with pytest.raises( exceptions.AttributeImmutability ): + del d.foo + + +def test_121_decorator_core_function_replacements_inheritance( ): + ''' Replacement core functions (via decorator) inherited. ''' + module = cache_import_module( MODULE_QNAME ) + + @module.with_standard_behaviors( + assigner_core = simple_assigner_core, + deleter_core = simple_deleter_core, + surveyor_core = simple_surveyor_core, + ) + class Base: pass + + b = Base( ) + b.bar = 6 + assert b.bar == 6 + assert 'bar' in dir( b ) + del b.bar + assert not hasattr( b, 'bar' ) + + @module.with_standard_behaviors( ) + class Derivation( Base ): pass + + d = Derivation( ) + d.foo = 2 + assert d.foo == 2 + assert 'foo' in dir( d ) + del d.foo + assert not hasattr( d, 'foo' ) + + +def test_220_cfc_core_function_inheritance( ): + ''' Core functions (via metaclass) inherited. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + + @module.class_factory( ) + class Class( type ): pass + + class Base( metaclass = Class ): pass + + with pytest.raises( exceptions.AttributeImmutability ): + Base.bar = 6 + assert not hasattr( Base, 'bar' ) + assert 'bar' not in dir( Base ) + with pytest.raises( exceptions.AttributeImmutability ): + del Base.bar + + class Derivation( Base ): pass + + with pytest.raises( exceptions.AttributeImmutability ): + Derivation.foo = 2 + assert not hasattr( Derivation, 'foo' ) + assert 'foo' not in dir( Derivation ) + with pytest.raises( exceptions.AttributeImmutability ): + del Derivation.foo + + +def test_221_cfc_core_function_replacements_inheritance( ): + ''' Replacement core functions (via metaclass) inherited. ''' + module = cache_import_module( MODULE_QNAME ) + + @module.class_factory( + assigner_core = class_simple_assigner_core, + deleter_core = class_simple_deleter_core, + surveyor_core = class_simple_surveyor_core, + ) + class Class( type ): pass + + class Base( metaclass = Class ): pass + + Base.bar = 6 + assert Base.bar == 6 + assert 'bar' in dir( Base ) + del Base.bar + assert not hasattr( Base, 'bar' ) + + class Derivation( Base ): pass + + Derivation.foo = 2 + assert Derivation.foo == 2 + assert 'foo' in dir( Derivation ) + del Derivation.foo + assert not hasattr( Derivation, 'foo' ) diff --git a/tests/test_000_classcore/test_340_standard_classes.py b/tests/test_000_classcore/test_340_standard_classes.py new file mode 100644 index 0000000..d349c0c --- /dev/null +++ b/tests/test_000_classcore/test_340_standard_classes.py @@ -0,0 +1,84 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +import pytest + +from .__ import PACKAGE_NAME, cache_import_module + +MODULE_QNAME = f"{PACKAGE_NAME}.standard.classes" + + +def test_124_cfc_instances_ignore_init_arguments( ): + ''' Metaclass respects 'instances_ignore_init_arguments'. ''' + from urllib.parse import ParseResult as UrlParts + from urllib.parse import urlparse + module = cache_import_module( MODULE_QNAME ) + + class Url( + module.Object, UrlParts, instances_ignore_init_arguments = True + ): pass + + u = Url( *urlparse( 'https://python.org' ) ) + assert u.scheme == 'https' + + class UrlWithInit( + module.Object, UrlParts, instances_ignore_init_arguments = True + ): + def __init__( self, *posargs, **nomargs ): + super( ).__init__( *posargs, **nomargs ) + + u = UrlWithInit( *urlparse( 'https://python.org' ) ) + assert u.scheme == 'https' + + +def test_125_cfc_function_local_dataclass( ): + ''' Function-local dataclass objects construct under slot + reproduction. During reproduction, the class is rebuilt with a + bare qualname, so the digest-mangled construction marker drifts; + detection must survive the drift. ''' + module = cache_import_module( MODULE_QNAME ) + + def produce( ): + class LocalData( module.DataclassObject ): + x: int + return LocalData + + LocalData = produce( ) + assert 'x' in LocalData.__dataclass_fields__ + assert '' in LocalData.__qualname__ + instance = LocalData( x = 3 ) + assert 3 == instance.x + + +@pytest.mark.parametrize( 'name', ( + 'DataclassObject', 'DataclassObjectMutable', + 'DataclassProtocol', 'DataclassProtocolMutable' ) ) +def test_126_cfc_function_local_dataclass_bases( name ): + ''' Every dataclass base class constructs when function-local. ''' + module = cache_import_module( MODULE_QNAME ) + + def produce( ): + class LocalBase( getattr( module, name ) ): + value: int + return LocalBase + + LocalBase = produce( ) + assert '' in LocalBase.__qualname__ + assert 'value' in LocalBase.__dataclass_fields__ diff --git a/tests/test_000_classcore/test_345_standard_protocols.py b/tests/test_000_classcore/test_345_standard_protocols.py new file mode 100644 index 0000000..51afc4d --- /dev/null +++ b/tests/test_000_classcore/test_345_standard_protocols.py @@ -0,0 +1,336 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + + +''' Tests for protocol classes and metaclass hierarchy. ''' + + +import typing + +from .__ import PACKAGE_NAME, cache_import_module + +MODULE_QNAME = f"{PACKAGE_NAME}.standard.classes" + +# Module-level cross-metaclass class: inherits from bases backed by +# ProtocolClass and ProtocolDataclass. Defined at module level to avoid +# the qualname/in_progress mangling interaction with dataclass _add_slots +# replacement that affects function-local classes. +_ccstd = cache_import_module( MODULE_QNAME ) + + +class _CrossMetaclassProto( _ccstd.Protocol, _ccstd.DataclassProtocol ): + ''' Exercises cross-metaclass inheritance. ''' + + def greet( self ) -> str: ... + + +class _ModuleDataclassProto( _ccstd.DataclassProtocol ): + ''' Module-level dataclass protocol (avoids function-local qualname). ''' + + value: int + + def greet( self ) -> str: ... + + +# =========================================================================== # +# Metaclass Hierarchy +# =========================================================================== # + + +def test_400_protocol_metaclass_hierarchy( ): + ''' Protocol metaclasses form a proper inheritance hierarchy. ''' + module = cache_import_module( MODULE_QNAME ) + assert issubclass( module.ProtocolDataclass, module.ProtocolClass ) + assert issubclass( module.ProtocolDataclassMutable, module.ProtocolClass ) + assert issubclass( + module.ProtocolDataclassMutable, module.ProtocolDataclass ) + + +def test_401_nonprotocol_metaclass_hierarchy( ): + ''' Non-protocol metaclasses form a proper inheritance hierarchy. ''' + module = cache_import_module( MODULE_QNAME ) + assert issubclass( module.Dataclass, module.Class ) + assert issubclass( module.DataclassMutable, module.Dataclass ) + + +def test_410_cross_metaclass_no_conflict( ): + ''' Metaclass hierarchy resolves conflicts between protocol metaclasses. + A class inheriting from bases backed by ProtocolClass and + ProtocolDataclass is created successfully, with ProtocolDataclass + (most derived) as its metaclass. + ''' + module = cache_import_module( MODULE_QNAME ) + assert issubclass( module.ProtocolDataclass, module.ProtocolClass ) + # The module-level class below exercises actual cross-metaclass + # inheritance without TypeError. + assert _CrossMetaclassProto._is_protocol + assert type( _CrossMetaclassProto ) is module.ProtocolDataclass + + +# =========================================================================== # +# Protocol Detection +# =========================================================================== # + + +def test_420_protocol_subclass_is_protocol( ): + ''' Direct subclasses of protocol base classes are recognized as + protocols at runtime. ''' + module = cache_import_module( MODULE_QNAME ) + + class DirectProto( module.Protocol ): + def method( self ) -> int: ... + + assert DirectProto.__dict__.get( '_is_protocol' ) is True + + class DirectMutableProto( module.ProtocolMutable ): + def method( self ) -> int: ... + + assert DirectMutableProto.__dict__.get( '_is_protocol' ) is True + + +# NOTE: DataclassProtocol subclasses are tested separately because the +# dataclass machinery's _add_slots creates a replacement class whose +# __qualname__ differs from the original when defined inside a function, +# which interacts with the in_progress mangling. This is a pre-existing +# issue unrelated to the protocol metaclass hierarchy fix. + + + +def test_421_concrete_implementation_not_protocol( ): + ''' Concrete implementation subclasses are not protocols. ''' + module = cache_import_module( MODULE_QNAME ) + + class ProtoIface( module.Protocol ): + def method( self ) -> int: ... + + class ConcreteImpl( ProtoIface ): + def method( self ) -> int: + return 42 + + assert ConcreteImpl.__dict__.get( '_is_protocol' ) is None or \ + ConcreteImpl.__dict__.get( '_is_protocol' ) is False + + +# =========================================================================== # +# Runtime Checkable Protocols +# =========================================================================== # + + +def test_430_runtime_checkable_via_decorators( ): + ''' @runtime_checkable works when applied via decorators= argument. ''' + module = cache_import_module( MODULE_QNAME ) + + class CheckableProto( + module.Protocol, + decorators = ( typing.runtime_checkable, ), + ): + def greet( self ) -> str: ... + + assert CheckableProto._is_runtime_protocol + + +def test_431_runtime_checkable_direct_application( ): + ''' @runtime_checkable works when applied directly after creation. ''' + module = cache_import_module( MODULE_QNAME ) + + class DirectProto( module.Protocol ): + def greet( self ) -> str: ... + + typing.runtime_checkable( DirectProto ) + assert DirectProto._is_runtime_protocol + + +def test_432_runtime_checkable_isinstance( ): + ''' isinstance() structural subtyping works with runtime_checkable. ''' + module = cache_import_module( MODULE_QNAME ) + + class GreeterProto( + module.Protocol, + decorators = ( typing.runtime_checkable, ), + ): + def greet( self ) -> str: ... + + class GreeterImpl: + def greet( self ) -> str: + return "hello" + + assert isinstance( GreeterImpl( ), GreeterProto ) + + +# =========================================================================== # +# Protocol Attributes +# =========================================================================== # + + +def test_440_protocol_attrs_exclude_internals( ): + ''' __protocol_attrs__ does not contain classcore internal attributes. ''' + module = cache_import_module( MODULE_QNAME ) + + class MyProto( module.Protocol ): + def greet( self ) -> str: ... + + attrs = MyProto.__dict__.get( '__protocol_attrs__' ) + assert attrs is not None + assert 'greet' in attrs + assert not any( a.startswith( '_classcore_' ) for a in attrs ) + assert '__setattr__' not in attrs + assert '__delattr__' not in attrs + assert '__dir__' not in attrs + + +def test_441_runtime_checkable_all_four_bases( ): + ''' Runtime structural subtyping (isinstance) works for direct + subclasses of all four protocol base classes. ''' + module = cache_import_module( MODULE_QNAME ) + + class Impl: + def greet( self ) -> str: + return "hello" + + for base in ( + module.Protocol, + module.ProtocolMutable, + module.DataclassProtocol, + module.DataclassProtocolMutable, + ): + proto_cls = type( f"{base.__name__}Greeter", ( base, ), { + 'greet': lambda self: 'hello', + '_is_protocol': True, + } ) + typing.runtime_checkable( proto_cls ) + assert proto_cls._is_runtime_protocol, ( + f"{base.__name__}: _is_runtime_protocol not set" ) + assert isinstance( Impl( ), proto_cls ), ( + f"{base.__name__}: isinstance failed" ) + + +def test_442_declared_dunder_preserved( ): + ''' Protocol members declared by the author, including dunders, are + preserved in __protocol_attrs__. Framework-generated dunders + (dataclass, behaviors) are excluded via the pre-decoration + snapshot, not a dunder blacklist. ''' + module = cache_import_module( MODULE_QNAME ) + + class DunderProto( module.Protocol ): + def __repr__( self ) -> str: ... + def greet( self ) -> str: ... + + attrs = DunderProto.__dict__[ '__protocol_attrs__' ] + assert '__repr__' in attrs + assert 'greet' in attrs + assert '__eq__' not in attrs + + +def test_446_typing_dunder_excluded_from_protocol_attrs( ): + ''' '__typing_is_deprecated_inherited_runtime_protocol__', set by + CPython 3.15+ typing machinery during class creation, is not a + declared protocol member. ''' + module = cache_import_module( MODULE_QNAME ) + + class TypingProto( module.Protocol ): + __typing_is_deprecated_inherited_runtime_protocol__ = True + def greet( self ) -> str: ... + + attrs = TypingProto.__dict__[ '__protocol_attrs__' ] + assert '__typing_is_deprecated_inherited_runtime_protocol__' not in attrs + assert 'greet' in attrs + + +def test_443_dataclass_protocol_attrs_declared_only( ): + ''' Dataclass protocol attrs contain declared fields and methods, + excluding dataclass-generated dunders. ''' + attrs = _ModuleDataclassProto.__dict__[ '__protocol_attrs__' ] + assert 'value' in attrs + assert 'greet' in attrs + assert '__repr__' not in attrs + assert '__dataclass_fields__' not in attrs + + +def test_444_factory_hierarchy_delegation( ): + ''' Custom metaclass hierarchies delegate construction without + duplicating postprocessing. Covers the no-stub wrapper branch: + a parent metaclass without '__new__' in its own dict. A class + decorator must record exactly one invocation. ''' + from classcore.standard.decorators import class_factory + + @class_factory( ) + class ParentMeta( type ): + ''' No '__new__' stub: factory installs construct_with_super. ''' + + @class_factory( ) + class ChildMeta( ParentMeta ): + + def __new__( # Typechecker stub. + clscls, name, bases, namespace, **kwargs + ): + return super( ).__new__( clscls, name, bases, namespace ) + + invocations: list[ str ] = [ ] + + def recorder( cls: type ) -> type: + invocations.append( cls.__name__ ) + return cls + + class Product( + metaclass = ChildMeta, decorators = ( recorder, ) + ): + ''' Created through both factory levels. ''' + + assert type( Product ) is ChildMeta + assert invocations == [ 'Product' ] + + +def test_445_function_local_dataclass_protocol( ): + ''' Function-local dataclass protocols construct despite digest + drift of the construction marker during slot reproduction. ''' + module = cache_import_module( MODULE_QNAME ) + + def produce( ): + class LocalProto( module.DataclassProtocol ): + y: int + return LocalProto + + LocalProto = produce( ) + assert LocalProto._is_protocol + assert 'y' in LocalProto.__protocol_attrs__ + assert 'y' in LocalProto.__dataclass_fields__ + + +# =========================================================================== # +# Dataclass Transform Preservation +# =========================================================================== # + + +def test_450_dataclass_transform_frozen_preserved( ): + ''' ProtocolDataclass has frozen_default=True. ''' + module = cache_import_module( MODULE_QNAME ) + spec = module.ProtocolDataclass.__dict__.get( '__dataclass_transform__' ) + assert spec is not None + assert spec.get( 'frozen_default' ) is True + + +def test_451_dataclass_transform_mutable_not_frozen( ): + ''' ProtocolDataclassMutable has frozen_default=False, not inherited + from ProtocolDataclass. ''' + module = cache_import_module( MODULE_QNAME ) + spec = ( + module.ProtocolDataclassMutable.__dict__.get( + '__dataclass_transform__' ) ) + assert spec is not None + assert spec.get( 'frozen_default' ) is False diff --git a/tests/test_000_classcore/test_347_abstract_classes.py b/tests/test_000_classcore/test_347_abstract_classes.py new file mode 100644 index 0000000..7993902 --- /dev/null +++ b/tests/test_000_classcore/test_347_abstract_classes.py @@ -0,0 +1,224 @@ +# vim: set filetype=python fileencoding=utf-8: +# -*- coding: utf-8 -*- + +#============================================================================# +# # +# Licensed under the Apache License, Version 2.0 (the "License"); # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# # +#============================================================================# + +''' Tests for abstract base class support. ''' + + +import abc +import typing + +import pytest + +from .__ import PACKAGE_NAME, cache_import_module + + +MODULE_QNAME = f"{PACKAGE_NAME}.standard.classes" +EXCEPTIONS_QNAME = f"{PACKAGE_NAME}.exceptions" + + +# =========================================================================== # +# Metaclass Hierarchy +# =========================================================================== # + + +def test_500_abstractclass_hierarchy( ): + ''' AbstractClass combines Class with ABCMeta in a diamond. ''' + module = cache_import_module( MODULE_QNAME ) + assert issubclass( module.AbstractClass, module.Class ) + assert issubclass( module.AbstractClass, abc.ABCMeta ) + # Class itself remains backed by plain type: no ABC machinery on + # ordinary classes. + assert not issubclass( module.Class, abc.ABCMeta ) + + +def test_501_protocolclass_unified_taxonomy( ): + ''' ProtocolClass descends from AbstractClass and Class. ''' + module = cache_import_module( MODULE_QNAME ) + assert issubclass( module.ProtocolClass, module.Class ) + assert issubclass( module.ProtocolClass, module.AbstractClass ) + assert issubclass( module.ProtocolClass, abc.ABCMeta ) + + +# =========================================================================== # +# AbstractObject +# =========================================================================== # + + +def test_510_abstractobject_standard_behaviors( ): + ''' AbstractObject applies standard behaviors to subclasses. ''' + module = cache_import_module( MODULE_QNAME ) + + class Widget( module.AbstractObject ): + ''' Concrete widget. ''' + + def provide( self ) -> int: + return 42 + + assert type( Widget ) is module.AbstractClass + widget = Widget( ) + assert widget.provide( ) == 42 + try: + widget.provide = lambda: 0 # type: ignore[method-assign] + mutated = True + except Exception: + mutated = False + assert not mutated, 'instance immutability not enforced' + + +def test_511_abstractobject_mixes_with_external_abc( ): + ''' AbstractObject subclasses mix with external ABC-based classes. ''' + module = cache_import_module( MODULE_QNAME ) + + class ExternalABC( abc.ABC ): + @abc.abstractmethod + def provide( self ) -> int: ... + + class Mixed( module.AbstractObject, ExternalABC ): + def provide( self ) -> int: + return 7 + + assert type( Mixed ) is module.AbstractClass + assert Mixed( ).provide( ) == 7 + + +def test_512_abstract_method_enforcement( ): + ''' Incomplete subclasses of abstract classes cannot instantiate. ''' + module = cache_import_module( MODULE_QNAME ) + + class ExternalABC( abc.ABC ): + @abc.abstractmethod + def provide( self ) -> int: ... + + class Incomplete( module.AbstractObject, ExternalABC ): + ''' Does not implement "provide". ''' + + try: + Incomplete( ) + raised = False + except TypeError: + raised = True + assert raised, 'abstract method enforcement missing' + + +def test_513_registration( ): + ''' Virtual subclass registration works and does not trip + immutability enforcement. ''' + module = cache_import_module( MODULE_QNAME ) + + class Registrable( module.AbstractObject ): + pass + + class Standalone: + pass + + Registrable.register( Standalone ) + assert issubclass( Standalone, Registrable ) + assert isinstance( Standalone( ), Registrable ) + + +# =========================================================================== # +# Protocol Functionality Preserved +# =========================================================================== # + + +def test_520_protocol_suite_through_unified_hierarchy( ): + ''' Protocol detection and structural subtyping still work after + ProtocolClass re-parenting. ''' + module = cache_import_module( MODULE_QNAME ) + + class GreeterProto( module.Protocol ): + def greet( self ) -> str: ... + + assert GreeterProto.__dict__.get( '_is_protocol' ) is True + + typing.runtime_checkable( GreeterProto ) + + class Impl: + def greet( self ) -> str: + return 'hello' + + assert isinstance( Impl( ), GreeterProto ) + + +def test_521_single_decoration_through_hierarchy( ): + ''' Decorators apply exactly once through the unified + ProtocolClass -> AbstractClass -> Class hierarchy. ''' + module = cache_import_module( MODULE_QNAME ) + invocations: list[ str ] = [ ] + + def recorder( cls: type ) -> type: + invocations.append( cls.__name__ ) + return cls + + class Decorated( + metaclass = module.ProtocolClass, + decorators = ( recorder, ), + ): + ''' Class created through three factory levels. ''' + + assert invocations == [ 'Decorated' ] + + +def test_522_concrete_classes_satisfy_protocols( ): + ''' Concrete classcore classes are structurally compatible with + classcore protocols. ''' + module = cache_import_module( MODULE_QNAME ) + + class GreeterProto( + module.Protocol, + decorators = ( typing.runtime_checkable, ), + ): + def greet( self ) -> str: ... + + class GreeterClass( module.Object ): + def greet( self ) -> str: + return 'concrete' + + assert isinstance( GreeterClass( ), GreeterProto ) + + +def test_523_typing_dunder_assignable_on_immutable_class( ): + ''' CPython 3.15+ typing machinery dunder is assignable on immutable + classes. Typing sets + '__typing_is_deprecated_inherited_runtime_protocol__' during + class decoration and in the 'runtime_checkable' decorator, so + the class-level immutability behavior exempts it. ''' + module = cache_import_module( MODULE_QNAME ) + + class GreeterProto( module.Protocol ): + def greet( self ) -> str: ... + + GreeterProto.__typing_is_deprecated_inherited_runtime_protocol__ = ( + False ) + assert ( + GreeterProto.__dict__[ + '__typing_is_deprecated_inherited_runtime_protocol__' ] + is False ) + + +def test_524_typing_names_refused_on_non_protocol_abstract( ): + ''' Typing-only mutability exemptions do not extend to non-protocol + abstract classes: only the ABC machinery set is mutable there. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions = cache_import_module( EXCEPTIONS_QNAME ) + + class Concrete( module.AbstractObject ): pass + + with pytest.raises( exceptions.AttributeImmutability ): + Concrete._is_runtime_protocol = True diff --git a/tests/test_000_classcore/test_350_standard_modules.py b/tests/test_000_classcore/test_350_standard_modules.py index 140ca88..15cc060 100644 --- a/tests/test_000_classcore/test_350_standard_modules.py +++ b/tests/test_000_classcore/test_350_standard_modules.py @@ -19,40 +19,114 @@ import types +import warnings -from . import PACKAGE_NAME, cache_import_module +import pytest + +from .__ import PACKAGE_NAME, cache_import_module MODULE_QNAME = f"{PACKAGE_NAME}.standard.modules" -def test_200_reclassification_of_package_module( ): - ''' Reclassifies package module directly. ''' +def test_200_reclassification_of_independent_module( ): + ''' Reclassifies independent module directly. ''' module = cache_import_module( MODULE_QNAME ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) module_class = module.Module module_ = types.ModuleType( 'foobarnotreal' ) module_.__package__ = None assert module_.__class__ is not module_class - module.reclassify_modules( module_ ) - assert module_.__class__ is module_class - module.reclassify_modules( module_ ) # idempotence - assert module_.__class__ is module_class + with warnings.catch_warnings( ): + warnings.simplefilter( 'ignore', DeprecationWarning ) + module.reclassify_modules( module_ ) + assert module_.__class__ is module_class + module.reclassify_modules( module_ ) # idempotence + assert module_.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + module_.foo = 1 def test_201_reclassification_of_normal_module( ): ''' Reclassifies normal module directly. ''' module = cache_import_module( MODULE_QNAME ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) module_class = module.Module module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) module_.__package__ = 'fakepackage' assert module_.__class__ is not module_class - module.reclassify_modules( module_ ) - assert module_.__class__ is module_class - module.reclassify_modules( module_ ) # idempotence - assert module_.__class__ is module_class + with warnings.catch_warnings( ): + warnings.simplefilter( 'ignore', DeprecationWarning ) + module.reclassify_modules( module_ ) + assert module_.__class__ is module_class + module.reclassify_modules( module_ ) # idempotence + assert module_.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + module_.foo = 1 + +def test_202_reclassification_of_package( ): + ''' Reclassifies package directly. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + module_class = module.Module + package_module = types.ModuleType( 'foobarnotreal' ) + package_module.__package__ = 'foobarnotreal' + member_module = types.ModuleType( 'foobarnotreal.member' ) + member_module.__package__ = 'foobarnotreal' + package_module.member = member_module + assert package_module.__class__ is not module_class + assert member_module.__class__ is not module_class + with warnings.catch_warnings( ): + warnings.simplefilter( 'ignore', DeprecationWarning ) + module.reclassify_modules( package_module ) + assert package_module.__class__ is module_class + assert member_module.__class__ is not module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + package_module.foo = 1 + member_module.foo = 1 + assert member_module.foo == 1 -def test_202_reclassification_of_incomplete_module( ): + +def test_203_reclassification_of_package_recursive( ): + ''' Reclassifies package recursively. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + module_class = module.Module + package_module = types.ModuleType( 'foobarnotreal' ) + package_module.__package__ = 'foobarnotreal' + member_module = types.ModuleType( 'foobarnotreal.member' ) + member_module.__package__ = 'foobarnotreal' + package_module.member = member_module + assert package_module.__class__ is not module_class + assert member_module.__class__ is not module_class + with warnings.catch_warnings( ): + warnings.simplefilter( 'ignore', DeprecationWarning ) + module.reclassify_modules( package_module, recursive = True ) + assert package_module.__class__ is module_class + assert member_module.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + package_module.foo = 1 + with pytest.raises( exceptions_module.AttributeImmutability ): + member_module.foo = 1 + + +def test_204_reclassification_of_module_exclude( ): + ''' Reclassification ignores excluded module. ''' + module = cache_import_module( MODULE_QNAME ) + module_class = module.Module + module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) + module_.__package__ = 'fakepackage' + assert module_.__class__ is not module_class + with warnings.catch_warnings( ): + warnings.simplefilter( 'ignore', DeprecationWarning ) + module.reclassify_modules( module_, excludes = { module_ } ) + assert module_.__class__ is not module_class + module_.foo = 1 + assert module_.foo == 1 + + +def test_205_reclassification_of_incomplete_module( ): ''' Reclassification ignores incomplete module. ''' module = cache_import_module( MODULE_QNAME ) module_class = module.Module @@ -60,18 +134,117 @@ def test_202_reclassification_of_incomplete_module( ): module_.__package__ = None del module_.__name__ assert module_.__class__ is not module_class - module.reclassify_modules( module_ ) + with warnings.catch_warnings( ): + warnings.simplefilter( 'ignore', DeprecationWarning ) + module.reclassify_modules( module_ ) assert module_.__class__ is not module_class -def test_205_reclassification_via_module_globals( ): +def test_206_reclassification_via_module_globals( ): ''' Reclassifies via module globals dictionary. ''' module = cache_import_module( MODULE_QNAME ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) module_class = module.Module module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) module_dict = { 'mod': module_, '__package__': 'fakepackage' } assert module_.__class__ is not module_class - module.reclassify_modules( module_dict ) + with warnings.catch_warnings( ): + warnings.simplefilter( 'ignore', DeprecationWarning ) + module.reclassify_modules( module_dict, recursive = True ) + assert module_.__class__ is module_class + module.reclassify_modules( module_dict, recursive = True ) # idempotent + assert module_.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + module.foo = 1 + + +def test_210_finalize_module_basic( ): + ''' Finalizes module with default parameters. ''' + module = cache_import_module( MODULE_QNAME ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + module_class = module.Module + module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) + module_.__package__ = 'fakepackage' + assert module_.__class__ is not module_class + module.finalize_module( module_ ) + assert module_.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + module_.foo = 1 + + +def test_211_finalize_module_recursive_with_module_targets( ): + ''' Finalizes module recursively when introspection has Module targets. ''' + module = cache_import_module( MODULE_QNAME ) + dynadoc_module = cache_import_module( f"{PACKAGE_NAME}.standard.dynadoc" ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + module_class = module.Module + module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) + module_.__package__ = 'fakepackage' + introspection_with_module = dynadoc_module.dynadoc_introspection_on_package + assert module_.__class__ is not module_class + module.finalize_module( + module_, + dynadoc_introspection = introspection_with_module, + recursive = True ) assert module_.__class__ is module_class - module.reclassify_modules( module_dict ) # idempotence + with pytest.raises( exceptions_module.AttributeImmutability ): + module_.foo = 1 + + +def test_212_finalize_module_recursive_without_module_targets( ): + ''' Finalizes module recursively when introspection lacks targets. ''' + module = cache_import_module( MODULE_QNAME ) + dynadoc_module = cache_import_module( f"{PACKAGE_NAME}.standard.dynadoc" ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + module_class = module.Module + module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) + module_.__package__ = 'fakepackage' + introspection_without_module = ( + dynadoc_module.dynadoc_introspection_on_class ) + assert module_.__class__ is not module_class + module.finalize_module( + module_, + dynadoc_introspection = introspection_without_module, + recursive = True ) + assert module_.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + module_.foo = 1 + + +def test_213_finalize_module_nonrecursive_with_module_targets( ): + ''' Finalizes module non-recursively when introspection has targets. ''' + module = cache_import_module( MODULE_QNAME ) + dynadoc_module = cache_import_module( f"{PACKAGE_NAME}.standard.dynadoc" ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + module_class = module.Module + module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) + module_.__package__ = 'fakepackage' + introspection_with_module = dynadoc_module.dynadoc_introspection_on_package + assert module_.__class__ is not module_class + module.finalize_module( + module_, + dynadoc_introspection = introspection_with_module, + recursive = False ) + assert module_.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + module_.foo = 1 + + +def test_214_finalize_module_nonrecursive_without_module_targets( ): + ''' Finalizes module non-recursively when introspection lacks targets. ''' + module = cache_import_module( MODULE_QNAME ) + dynadoc_module = cache_import_module( f"{PACKAGE_NAME}.standard.dynadoc" ) + exceptions_module = cache_import_module( f"{PACKAGE_NAME}.exceptions" ) + module_class = module.Module + module_ = types.ModuleType( 'fakepackage.foobarnotreal' ) + module_.__package__ = 'fakepackage' + introspection_without_module = ( + dynadoc_module.dynadoc_introspection_on_class ) + assert module_.__class__ is not module_class + module.finalize_module( + module_, + dynadoc_introspection = introspection_without_module, + recursive = False ) assert module_.__class__ is module_class + with pytest.raises( exceptions_module.AttributeImmutability ): + module_.foo = 1