Pkg Ext
A CLI tool for managing Python package public API, versioning, and changelog generation.
Overview
pkg-ext tracks which symbols (functions, classes, exceptions) in your package are "exposed" (public) vs "hidden" (internal). It:
- Generates
__init__.pywith imports and__all__based on decisions stored in changelog entries - Creates group modules (e.g.,
my_group.py) that re-export related symbols - Maintains a structured changelog directory (
.changelog/) per PR - Bumps version based on changelog action types (make_public=minor, fix=patch, delete/rename=major)
- Writes a human-readable
CHANGELOG.md - Provides stability decorators (
@experimental,@deprecated) with suppressible warnings - Generates
_warnings.pyin target packages to avoid runtime pkg-ext dependency
Installation
uv pip install pkg-ext
# or
pip install pkg-ext
Core Concepts
Symbol Reference IDs
Symbols are identified by {module_path}.{symbol_name}, e.g., my_pkg.utils.parse_config.
Changelog Actions
Stored in .changelog/{pr_number}.yaml files using Pydantic discriminated unions:
| Action Type | Description | Version Bump | Key Fields |
|---|---|---|---|
make_public |
Make symbol public | Minor | group, details |
keep_private |
Keep symbol internal | None | full_path |
fix |
Bug fix from git commit | Patch | short_sha, message, changelog_message, ignored |
delete |
Remove from public API | Major | group |
rename |
Rename with old alias | Major | group, old_name |
breaking_change |
Breaking API change | Major | group, details |
additional_change |
Non-breaking change | Patch | group, details |
group_module |
Assign module to a group | None | module_path |
release |
Version release marker | None | old_version |
experimental |
Mark as experimental | Patch | target, group/parent |
ga |
Graduate to GA | Patch | target, group/parent |
deprecated |
Mark as deprecated | Patch | target, group/parent, replacement |
max_bump_type |
Cap version bump | None | max_bump, reason |
chore |
Internal changes | Patch | description |
All actions inherit common fields: name, ts, author, pr.
The breaking_change and additional_change actions support optional fields for API diff:
change_kind: str | None- machine-readable change type (e.g.,param_removed,default_changed)auto_generated: bool-truewhen created by API diff,falsefor interactive actionsfield_name: str | None- field name for field-level changes
Stability Targets
Stability actions (experimental, ga, deprecated) support three target levels:
| Target | Description | Required Field |
|---|---|---|
group |
Entire group | name = group name |
symbol |
Single symbol | group + name = symbol name |
arg |
Function argument | parent = {group}.{symbol}, name = arg name |
Public Groups
Groups organize related symbols. Configured in .groups.yaml:
groups:
- name: __ROOT__ # Top-level exports in __init__.py
owned_refs: []
owned_modules: []
- name: my_group
owned_refs:
- my_pkg.utils.parse_config
owned_modules:
- my_pkg.utils
When a new symbol is exposed, the tool prompts you to select which group it belongs to. All symbols from the same module go to the same group.
CLI Commands
pkg-ext [OPTIONS] COMMAND
Global Options
| Option | Description |
|---|---|
-p, --path, --pkg-path |
Package directory path (auto-detected if not provided) |
--repo-root |
Repository root (auto-detected from .git) |
--is-bot |
CI mode: no prompts, fail on missing decisions |
--skip-open |
Skip opening files in editor |
--tag-prefix |
Git tag prefix (e.g., v for v1.0.0) |
Command Reference
| Category | Commands | Description | Docs |
|---|---|---|---|
| Workflow | pre-change, pre-commit, post-merge, change-base |
Development lifecycle commands | docs/workflows |
| Changelog | chore, promote, release-notes |
Changelog management | docs/changelog |
| Stability | exp, ga, dep |
Stability level management | docs/stability |
| API | diff-api, dump-api |
API comparison and export | docs/api_commands |
| Generation | gen-docs |
Generate API documentation | docs/generate |
| Examples | gen-example-prompt, check-examples |
Example doc generation and validation | docs/example |
When to Use Workflow Commands
| Scenario | Command |
|---|---|
| Added or removed symbols | pre-change |
| Final validation before commit | pre-commit |
| Single command for everything | pre-change --full |
| CI/CD pipeline | pre-commit (bot mode) |
| Re-targeted a stacked PR | change-base --new-base main |
pre-changehandles interactive decisions (expose/hide symbols, delete/rename)pre-commitvalidates all decisions are made (fails in bot mode if prompts needed), syncs generated files, regenerates docs, and runs the dirty checkpre-change --fullcombines both: runs interactive prompts, then syncs files and regenerates docs. The dirty check is skipped since you're still developingchange-baseconsolidates changelog files from closed PRs after re-targeting a stacked PR to a new base branch
Configuration
User Config (~/.config/pkg-ext/config.toml)
[user]
editor = "cursor" # or "code", "vim", etc.
skip_open_in_editor = false
Project Config (pyproject.toml)
[tool.pkg-ext]
tag_prefix = "v"
file_header = "# Generated by pkg-ext"
expose_mode = "prompt" # prompt | cli_only | opt_in
commit_fix_prefixes = ["fix:", "fix(", "bugfix:", "hotfix:"]
commit_diff_suffixes = [".py", ".pyi"]
changelog_cleanup_count = 30 # Archive when count exceeds this
changelog_keep_count = 10 # Keep this many after cleanup
format_command = ["ruff", "format"] # ruff check --fix always runs first
max_bump_type = "minor" # Cap version bumps (patch, minor, major)
# after_file_write_hooks = ["extra-cmd {pkg_path}"] # Custom post-write hooks
expose_mode controls how pre-change decides visibility for new symbols:
prompt(default): Auto-expose Typer commands; prompt to expose other tracked symbols.cli_only: Auto-expose Typer commands and their local package arg types; writekeep_privatefor everything else without the expose prompt. Still prompts for a group when a new public CLI module has none.opt_in: Writekeep_privatefor every new symbol, including Typer commands. Usepkg-ext promotelater to make symbols public.
pre-change --keep-private overrides config for one run (same as opt_in).
Group Configuration
Define groups with explicit settings in pyproject.toml:
[tool.pkg-ext.groups.my_group]
dependencies = ["__ROOT__"] # Groups this depends on
docs_exclude = ["internal_helper"]
docstring = "Utilities for common operations"
Note: Stability is not configured here. Use pkg-ext exp/ga/dep CLI commands to manage stability via changelog actions.
Version Bump Limits
For pre-1.0.0 packages where breaking changes are expected, cap the version bump:
Project-level (applies to all PRs):
# pyproject.toml
[tool.pkg-ext]
max_bump_type = "minor" # All PRs capped to minor
Per-PR override (MaxBumpTypeAction in changelog overrides config):
# .changelog/{pr}.yaml
name: version_cap
type: max_bump_type
max_bump: patch
reason: Documentation-only release
ts: '2026-01-17T14:35:00+00:00'
Dev Mode
The pre-commit command enables dev mode, which writes to -dev suffixed files:
.groups-dev.yamlinstead of.groups.yamlCHANGELOG-dev.mdinstead ofCHANGELOG.md
This allows iterating on changelog entries during development without modifying the production files. The real files are only updated by post-merge after PR is merged.
Generated Files
Files Updated During PR
| File | Purpose | Editable |
|---|---|---|
.changelog/{pr}.yaml |
Changelog actions for this PR | Yes |
.groups-dev.yaml |
Group assignments (dev copy) | No |
CHANGELOG-dev.md |
Human-readable changelog (dev copy) | No |
{pkg}.api-dev.yaml |
API dump for dev comparison (gitignored) | No |
{pkg}/__init__.py |
Package exports (VERSION unchanged) | No |
{pkg}/{group}.py |
Group re-export modules | No |
{pkg}/_warnings.py |
Stability warning decorators | No |
docs/**/*.md |
API documentation | Yes (outside markers) |
__init__.pyexports are updated but VERSION remains unchanged until release- Symbol doc pages include a "Changes" table showing unreleased modifications
- Content outside
=== OK_EDIT: pkg-ext ... ===markers can be customized and is preserved - Example docs are markdown files under
docs/examples/{group}/{symbol}.md, managed bygen-example-promptand validated bycheck-examples
Files Updated During Release
These files are updated by post-merge after PR is merged (main branch only):
| File | What Changes |
|---|---|
.groups.yaml |
Copied from .groups-dev.yaml |
CHANGELOG.md |
Copied from CHANGELOG-dev.md |
{pkg}/__init__.py |
VERSION updated to new version |
pyproject.toml |
Version field updated (if used) |
{pkg}.api.yaml |
Regenerated with new version |
docs/**/*.md |
Unreleased changes become versioned |
File Contents Examples
__init__.py:
# Generated by pkg-ext
# flake8: noqa
from my_pkg import my_group
from my_pkg.utils import parse_config
VERSION = "0.1.0"
__all__ = [
"my_group",
"parse_config",
]
Group module (my_group.py):
# Generated by pkg-ext
from my_pkg.helpers import helper_func as _helper_func
from my_pkg._warnings import _experimental
helper_func = _experimental(_helper_func) # With experimental stability
The underscore alias pattern prevents re-export issues with __all__.
_warnings.py:
When any group has non-GA stability, pkg-ext generates a _warnings.py module in the target package (removes runtime dependency on pkg-ext):
class MyPkgWarning(UserWarning): ...
class MyPkgExperimentalWarning(MyPkgWarning): ...
class MyPkgDeprecationWarning(MyPkgWarning, DeprecationWarning): ...
Symbol Detection
The tool parses Python files using AST to find:
- Functions - Public functions (not starting with
_) - Classes - Public classes
- Exceptions - Classes inheriting from
ExceptionorBaseException - Type Aliases - Names ending with
T - Global Variables - UPPERCASE names with 2+ characters
Files skipped:
__init__.py,__main__.py(dunder files)*_test.py,test_*.py,conftest.py(test files)- Files starting with the configured
file_header(already generated)
Automatic Behaviors
Function Argument Exposure
When exposing a function, its type hint arguments are auto-exposed if they reference local package types.
Git Integration
- Uses GitPython for commit analysis
- Uses gh CLI to detect PR info
- Extracts PR number from merge commit (
Merge pull request #123) or squash merge (feat: ... (#123)) messages
API Diff and Breaking Change Detection
During pre-commit, pkg-ext compares {pkg}.api.yaml (baseline from last release) against {pkg}.api-dev.yaml (current code) to detect API changes.
Detected Change Types
| Change | Breaking? | change_kind |
|---|---|---|
| Parameter removed | Yes | param_removed |
| Required parameter added | Yes | required_param_added |
| Parameter type changed | Yes | param_type_changed |
| Return type changed | Yes | return_type_changed |
| Default removed | Yes | default_removed |
| Required field added | Yes | required_field_added |
| Field removed | Yes | field_removed |
| Base class removed | Yes | base_class_removed |
| Base class added | No | base_class_added |
| Optional parameter added | No | optional_param_added |
| Default added | No | default_added |
| Default changed | No | default_changed |
| Optional field added | No | optional_field_added |
Auto-Generated Actions
API diff creates BreakingChangeAction or AdditionalChangeAction entries with auto_generated: true. These are:
- Replaced on each
pre-commitrun - Keyed by
(name, group, type, change_kind)for deduplication - Timestamps preserved for unchanged changes
Interactive actions (from pre-change) are never replaced.
First Release
When no baseline {pkg}.api.yaml exists, diff is skipped (nothing to compare against).
Limitations
Symbol Detection
- Type aliases require
Tsuffix - e.g.,ConfigTnotConfig - Global vars require UPPERCASE - e.g.,
DEFAULT_TIMEOUTnotdefault_timeout - Exceptions require
Errorsuffix - e.g.,ParseErrornotParseException - No relative import support - Only
from pkg.module import ...is tracked
Group Handling
- One group per module - All symbols from a module belong to the same group
- Cannot move symbols between groups - Once assigned, module-to-group mapping is fixed
- Root group always exists - Cannot be removed, used for top-level exports
Git Requirements
- Requires
ghCLI for PR info detection - PR number in commit message - supports
Merge pull request #123 from ...and squash merge... (#123)formats - Single remote assumed - Uses first remote for URL
Changelog
- PR-based storage - Each PR gets one
.yamlfile - No conflict resolution - Manual merge of
.changelog/files needed - Archiving by PR number - Old entries archived to
.changelog/000/*.yaml
Version Bumping
- SemVer only - No calendar versioning support
pyproject.tomlor__init__.py- Version must exist in one of these- Pre-release suffixes - Supports
rc,a(alpha),b(beta)
Interactive Mode
- Removed reference handling incomplete -
select_refandselect_multiple_ref_stateraiseNotImplementedError. This breaks rename workflows when symbols are removed. - Alias creation not implemented -
confirm_create_aliasalways returnsFalse
Stability
- Non-callable symbols - Constants and type aliases in experimental/deprecated groups don't emit warnings.
@experimentaland@deprecatedonly work on functions and classes. - Arg-level only for GA groups - Cannot track arg-level stability changes until group is GA.
API Diff
- No rename detection - Renames are treated as remove + add (two separate actions)
- Return types always breaking - No semantic analysis (e.g., returning subclass is flagged as breaking)
- Factory defaults - Defaults using
"..."(factory pattern) may cause false positives
Dependencies
- ask-shell - Interactive prompts and shell execution
- model-lib - YAML/TOML parsing and Pydantic models
- GitPython - Git repository access
Appendix
File Structure
my-repo/
CHANGELOG.md # Human-readable changelog
.groups.yaml # Group definitions
.changelog/ # Per-PR changelog actions
123.yaml # Actions from PR #123
000/ # Archived old entries
001.yaml
my_pkg/
__init__.py # Generated exports
my_group.py # Generated group module
_warnings.py # Generated stability module (if needed)
utils.py # Source file
_internal.py # Private module (ignored)
CI Configuration
GitHub Actions:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install pkg-ext
- run: pkg-ext pre-commit
release:
if: github.ref == 'refs/heads/main'
needs: validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pip install pkg-ext
- run: pkg-ext post-merge --push
Contributing
See CONTRIBUTING.md for development setup, workflow, and git hooks.
Metadata
Release files for pkg-ext 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pkg_ext-0.5.0.tar.gz | 80.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pkg_ext-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 195.6 kB
Release files / pkg_ext-0.5.0.tar.gz
| Download URL | pkg_ext-0.5.0.tar.gz |
|---|---|
| Size | 80.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
29df4425b7bf1b002225e5eb2c2662593370f4895f430c434f3441b22edf92f2
|
|
BLAKE2b-256 checksum How to use checksums |
1233b7ae6a4f8db7029014c2232bcf161a91992986186b9ab251c73e87af5e75
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.
Transparency logRelease files / pkg_ext-0.5.0-py3-none-any.whl
| Download URL | pkg_ext-0.5.0-py3-none-any.whl |
|---|---|
| Size | 115.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
58b7c2751d3ffce5646fb6e8ced4730ea3d8813cb1bc4169b3a9ab180fdcfa84
|
|
BLAKE2b-256 checksum How to use checksums |
011e5c6320847bbf271fd0b6f89ec93aa5b6aa8bc55dd12b0f11a73a09855643
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 5, 2026.
Transparency log