Skip to content

Comments

ci: adjust minimum Python version in broad charm compatibility tests#2317

Open
tonyandrewmeyer wants to merge 49 commits intocanonical:mainfrom
tonyandrewmeyer:compat-tests-python-version
Open

ci: adjust minimum Python version in broad charm compatibility tests#2317
tonyandrewmeyer wants to merge 49 commits intocanonical:mainfrom
tonyandrewmeyer:compat-tests-python-version

Conversation

@tonyandrewmeyer
Copy link
Collaborator

@tonyandrewmeyer tonyandrewmeyer commented Feb 10, 2026

A large number of our broad charm compatibility tests fail because the charm declares that it works with Python 3.8. That conflicts with us pushing the latest ops 3.x into the dependencies.

Generally, if we've made a change to ops[...] that breaks charms, that will be across Python versions, and we're more concerned with 3.10+ anyway (and in main, we're only supporting 3.10+). We could have some compatibility tests that check the latest 2.x branch for charms that expect 3.8, but that doesn't seem as useful.

This PR adjusts the dependency patching to also handle bumping the Python version to 3.10. It reduces the number of failures to 29, some of which are only for static checks (which I haven't worked on in this PR at all).

Since the changes are more complex now, they are moved out of the action file (YAML+bash) and into a helper (Python) script. Primarily, we do the work through tox, which I found to be the most consistent way to handle things across packaging tools (particularly given that we are using wheels now rather than git references).

The script also adds support for tox.toml, as some charms use that rather than tox.ini.

We also set PIP_IGNORE_REQUIRES_PYTHON to 1.

There is also a specific fix for wordpress-k8s, which can't handle 3.12 because of missing wheels. We could perhaps skip this charm instead, but it seems like a common example so one we should support (and it seems like too much of a job to add the missing 3.12 support).

Drive-by to fix mysql-router-operators which is now a monorepo (and some assorted changes that come through re-running the update script).

tonyandrewmeyer and others added 30 commits February 3, 2026 20:25
The previous approach used --lock flag which doesn't exist in Poetry 2.0+.
Instead, update pyproject.toml directly with sed and use 'poetry update'
to refresh just the ops and ops-scenario packages in the lock file.
…gracefully

Poetry 2.0+ does not have a --no-update flag. Changed to use 'poetry lock'
after removing the old lock file to force fresh resolution.

Also handle lock failures gracefully - if Poetry cannot resolve dependencies
due to transitive dependency conflicts (e.g., charm libraries requiring
ops <3.0), print a warning and continue. The test will fail later during
tox run, which correctly identifies the charm as incompatible with ops 3.x.
…deps

For Poetry-based charms, instead of trying to resolve ops 3.x with Poetry
(which fails when transitive dependencies require ops <3.0), we now:

1. Install poetry-plugin-export for potential future use
2. Leave poetry.lock unchanged and let Poetry install from it successfully
3. Use tox commands_post to force-reinstall ops 3.x and ops-scenario with pip

This bypasses Poetry's strict dependency resolver while still testing the
charm with ops 3.x, allowing us to identify real incompatibilities vs just
dependency constraint issues.
The 'poetry self add poetry-plugin-export' command was failing because
Poetry tries to modify system packages (certifi) without permission,
breaking ALL tests including non-Poetry ones.

The plugin isn't needed for our current approach anyway - we use
commands_post with pip to force-reinstall ops, not poetry export.
Tox requires external commands like pip to be explicitly allowed via
allowlist_externals. Without this, commands_post fails with:
'pip is not allowed, use allowlist_externals to allow it'

This fixes Poetry-based charm tests that use commands_post to
force-reinstall ops 3.x after poetry install.
…ress-k8s for Python 3.11

- Change uv handling to match Poetry approach: use pip force-reinstall in tox commands_post
- This bypasses uv's dependency resolution and forces ops 3.x installation
- Fixes traefik-k8s-operator (ops-scenario 6.x doesn't exist)
- Fixes temporal-worker-k8s-operator (ops 2.21.1 doesn't exist)
- Add special case for wordpress-k8s to cap Python at 3.11 due to mysql-connector-python lacking 3.12 wheels
After we update requires-python in pyproject.toml, the uv.lock file becomes
out of sync. Running 'uv lock' regenerates it with the new Python constraints
before tox runs 'uv sync --locked'.
…y files

- Check if allowlist_externals already exists before adding it
- If it exists, append 'pip' to the existing list instead of creating duplicate
- This fixes mongodb-k8s-operator and parca-agent-operator (both uv-based)
- Also fixes grafana-agent-k8s-operator (poetry-based)

- Change missing dependency file error to warning
- Instead of exiting with error, just skip ops version update
- This allows catalogue-k8s and indico to run their tests
- These charms may not use Python dependencies or have non-standard setup
indico-operator has ops[testing]==2.21.1 in its tox.ini deps section.
This version doesn't exist and ops 2.21.1 doesn't have a [testing] extra.

Solution:
- Remove ops and ops-scenario lines from tox.ini deps sections
- Add commands_post with pip force-reinstall for ops 3.x
- This follows the same pattern as poetry/uv-based charms
The sed command had incorrect syntax with '-e' flag in wrong position.
Changed from: sed -i "... { .../a\    pip" -e "}" tox.ini
Changed to:   sed -i "... { .../a\    pip\n}" tox.ini

This fixes the 'unexpected }' error seen in grafana-agent-k8s and other charms.
1. Update workflow generator script:
   - Add mysql-router-k8s-operator to SKIP (read-only mirror)
   - Add mysql-router-operators with folders 'machines' and 'kubernetes'

2. Manually update workflow matrix:
   - Remove mysql-router-k8s-operator entry
   - Add mysql-router-operators with both folders

3. Fix allowlist_externals append logic:
   - Previous sed syntax was broken and didn't preserve existing entries
   - Changed to simpler approach: add pip right after allowlist_externals line
   - This preserves existing entries like 'make' in vault-k8s
   - Applied to all sections: poetry, uv, and indico inline deps

This fixes vault-k8s 'make is not allowed' error and mysql-router-k8s missing tox.ini.
The issue was that even though we set requires-python = '>=3.10,<3.12',
the system Python was still 3.12.3 and uv was trying to use it.

Solution:
- Use actions/setup-python@v5 to install Python 3.11 for wordpress-k8s
- This makes Python 3.11 the default python3 for this charm
- Update the sed patterns to replace ANY requires-python value, not just >=3.10
- Write .python-version as '3.11' directly instead of sed replacement

This ensures uv creates the venv with Python 3.11 which has mysql-connector-python wheels.
The actions/setup-python@v5 step already makes Python 3.11 the default,
so we don't need to also modify .python-version file. We only need to
cap the requires-python in pyproject.toml to <3.12.
vault-k8s and other charms define allowlist_externals as a multi-line list:
  allowlist_externals =
      make

Previous sed commands tried to append to the 'allowlist_externals =' line itself,
which didn't preserve existing entries properly.

Fixed approach:
- Find the 'allowlist_externals =' line
- Add 'pip' as an indented entry right after it
- This preserves all existing entries like 'make'
- Applied to all sections: poetry, uv, and indico inline deps

This fixes vault-k8s-operator 'make is not allowed' errors.
Changed from /^\[testenv/ to /^\[testenv:/ in the range end pattern.
The colon makes it match the next section header properly.
Also removed the double backslash before 'pip' - should be single backslash.

This fixes the 'unmatched {' error in grafana-agent-k8s and other charms.
Created .github/scripts/patch-charm-deps.sh to consolidate all the
dependency patching logic that was scattered throughout the workflow.

Benefits:
- Much more readable workflow file (107 lines -> 7 lines for the step)
- Deduplicates logic for pip/Poetry/uv handling
- Adds clear logging showing which dependency system is detected
- Easier to maintain and test independently
- Single source of truth for patching logic

The script handles:
- requirements.txt-based charms (pip)
- Poetry-based charms (poetry.lock)
- uv-based charms (uv.lock)
- Inline tox.ini deps
- Multi-line allowlist_externals format
- Logging what actions are being taken

The workflow now just calls the script with the git URLs for ops and ops-scenario.
- Checkout operator repo first to get helper scripts
- Copy scripts to /tmp/operator-scripts
- Delete operator repo checkout before checking out charm repo
- Update all working-directory references to use charm-repo/ prefix
- Use /tmp/operator-scripts/patch-charm-deps.sh for the script path

This prevents the operator repo from interfering with charm tests
while still allowing us to use the helper scripts.
Updated patch-charm-deps.sh to accept wheel file paths instead of git URLs:
- Changed parameters from git URLs to wheel paths
- Updated requirements.txt patching to add wheel paths
- Simplified Poetry handling: use 'poetry add wheel --lock'
- Simplified uv handling: use 'uv add --frozen --raw-sources wheel'
- Removed tox commands_post approach (no longer needed with wheels)

This prepares for merging with main branch which uses wheels.
Resolved conflicts to use wheels instead of git URLs:
- Added build-wheels job dependency
- Added download-artifact step to get wheels
- Updated script call to pass wheel paths instead of git URLs
- Helper script already updated to handle wheels

The workflow now builds wheels first, then uses them for testing.
The helper script now tracks whether any dependency files were successfully
updated and fails with a clear error message if nothing was patched. This
prevents false positives where the job appears to succeed but ops was never
actually updated to 3.x.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
tonyandrewmeyer and others added 14 commits February 4, 2026 22:30
…17)

* Initial plan

* fix: address review comments from bot reviewer

Co-authored-by: tonyandrewmeyer <826522+tonyandrewmeyer@users.noreply.github.com>

* Update .github/patch-charm-deps.sh

* Update .github/patch-charm-deps.sh

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tonyandrewmeyer <826522+tonyandrewmeyer@users.noreply.github.com>
Co-authored-by: Tony Meyer <tony.meyer@canonical.com>
The sed command for appending pip to allowlist_externals was missing
the closing brace, causing 'unmatched {' errors.
Sed range patterns with braces need proper line breaks for the closing
brace. Using multi-line format to avoid syntax errors.
uv doesn't support adding local wheels to the lock file with 'uv add'.
Instead, use the same approach as Poetry: patch tox.ini to force-reinstall
the wheels after normal dependency installation.
Created patch_tox_testenv_sections() helper function to consolidate the
repeated logic for patching tox.ini across Poetry and uv charms. This
reduces duplication and makes the code more maintainable.
When tox-uv is managing the environment, we need to use 'uv pip install'
instead of 'pip install' to ensure packages are installed in the correct
location within the uv-managed environment.
Rewrote the helper script in Python for better maintainability and robustness:
- More readable string manipulation with regex
- Consolidated Python version handling into the script
- Added --max-python-version flag for wordpress-k8s compatibility
- Handles tox.ini, pyproject.toml, and .python-version updates
- Detects tox-uv and uses 'uv pip install' when appropriate

This removes ~50 lines of complex bash/sed from the workflow and makes
the logic much easier to understand and maintain.
Replaced regex-based parsing with proper parsers:
- configparser for tox.ini files
- tomllib for pyproject.toml files
- Regex only as fallback for malformed TOML

This makes the script more robust and maintainable by using the standard
library parsers instead of fragile regex patterns.
Extended the script to handle both tox.ini and tox.toml formats:
- Added get_tox_config_path() to find either tox.toml or tox.ini
- Implemented add_tox_pip_commands_toml() for TOML format
- Updated all tox-handling functions to support both formats
- TOML section naming: testenv -> env_run_base, testenv:unit -> env.unit

This enables aproxy-operator and other charms using tox.toml to work
with the compatibility tests.
Split the large function into three focused helpers:
- update_tox_python_version(): Handles tox.ini/tox.toml updates
- update_pyproject_python_version(): Handles pyproject.toml updates
- update_python_version_file(): Handles .python-version updates

This improves readability and makes each function easier to test and maintain.
Check if allowlist_externals and commands_post already exist before
adding them. This prevents 'Cannot overwrite a value' errors when
patching tox.toml files that already have these keys defined.

Also check if 'pip' is already in allowlist_externals before appending.
@tonyandrewmeyer tonyandrewmeyer marked this pull request as ready for review February 10, 2026 09:08
@dimaqq
Copy link
Contributor

dimaqq commented Feb 10, 2026

+1 on skipping wordpress

Copy link
Contributor

@dimaqq dimaqq left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm generally on board with this.

Comment on lines 61 to 64
if config.has_option(section, 'envlist'):
envlist = config.get(section, 'envlist')
new_envlist = envlist.replace('py38', 'py310').replace('py39', 'py310')
new_envlist = new_envlist.replace('{py38}', '{py310}').replace('{py39}', '{py310}')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this only be done if there's no py310 env yet?

content,
)
else:
print(' Found existing allowlist_externals, appending pip')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty complex.
That being said I can't necessarily think of anything else, apart from maybe python -m pip ...

break

if use_uv_pip:
print(" Detected tox-uv, will use 'uv pip install'")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to match pyproject.toml and relock instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I started out with separate solutions that focused on pyproject.toml. The problem is that there are so many ways that the dependencies can be present, sometimes multiple within a charm:

  • pyproject.toml, uv
  • pyproject.toml, poetry
  • requirements.txt, pip
  • dependencies listed in tox

The only thing we definitely have is tox, because we don't support running tests with anything else (make, just) at the moment. So it ended up being cleanest to just lean into using tox, even though it's not what you'd typically do for really managing these dependencies.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script will be hard to debug when one day something breaks.

I don't have a good idea how to make it better, but I feel I have to call this out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't disagree, although I would say that the previous YAML file approach was also not particularly simple to debug either. I think it is made easier by being a standalone script, and also by being Python rather than a bunch of shell commands. However, that's countered by it doing much more than before :(

Copy link
Contributor

@james-garner-canonical james-garner-canonical left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the overall approach of switching to a Python script for this more complicated logic. Going via tox to avoid too much special casing for all the different ecosystem tools seems like a great idea.

I do find the script hard to digest, I think primarily because there's a lot of long nesting conditionals. I think it would be a great idea to refactor a little bit to use more helpers and early returns / continues to make the high-level logic easier to follow. I've left some suggestions along these lines as I've scanned through.

I'm also wondering if unit tests for the factored out units might be nice to have, but they're not a blocker for me. I do wonder how we'll keep track of what's working as intended, but tracking what's passing and failing across multiple runs could be a separate project altogether.

tonyandrewmeyer and others added 2 commits February 20, 2026 13:10
Co-authored-by: Dima Tisnek <dimaqq@gmail.com>
Co-authored-by: Dima Tisnek <dimaqq@gmail.com>
Copy link
Contributor

@james-garner-canonical james-garner-canonical left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm getting my head around this new approach. My biggest question is: is patching commands_post actually doing what we want, or should we be patching commands_pre?

I've made a number of suggestions that I think will improve the script's digestibility as I go through the logic. The logic itself seems solid, though it's difficult to verify.

Comment on lines +569 to +576
new_deps_lines = [
line.strip()
for line in deps.split('\n')
if line.strip() and not _is_ops_dependency_line(line.strip())
]

original_count = len([ln for ln in deps.split('\n') if ln.strip()])
if len(new_deps_lines) == original_count:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
new_deps_lines = [
line.strip()
for line in deps.split('\n')
if line.strip() and not _is_ops_dependency_line(line.strip())
]
original_count = len([ln for ln in deps.split('\n') if ln.strip()])
if len(new_deps_lines) == original_count:
deps_lines = [l for line in deps.splitlines() if (l := line.strip())]
new_deps_lines = [l for l in deps_lines if not _is_ops_dependency_line(l)]
if deps_lines == new_deps_lines:

Comment on lines +54 to +58
if config.has_option(section, 'basepython'):
basepython = config.get(section, 'basepython')
if basepython in ('python3.8', 'python3.9'):
config.set(section, 'basepython', 'python3.10')
modified = True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if config.has_option(section, 'basepython'):
basepython = config.get(section, 'basepython')
if basepython in ('python3.8', 'python3.9'):
config.set(section, 'basepython', 'python3.10')
modified = True
if config.get(section, 'basepython', fallback=None) in ('python3.8', 'python3.9'):
config.set(section, 'basepython', 'python3.10')
modified = True

print(f' ✓ Updated {tox_config.relative_to(charm_root)}')

elif tox_config.suffix == '.toml':
# Handle tox.toml using regex (no standard library for writing TOML)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about using a library for this?

For example, we could do something like this (just need to install uv in the workflow):

#!/usr/bin/env -S uv run --script --no-project

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "tomli-w~=1.2.0",
# ]
# ///

and then

original = tomllib.loads(tox_config.read_text())
content = copy.deepcopy(original)
content['basepython'] = 'python3.10'
# ...
if content != original:
    tox_config.write_text(tomli_w.dumps(content))

Comment on lines +67 to +68
new_envlist = new_envlist.replace('{py38}', '{py310}')
new_envlist = new_envlist.replace('{py39}', '{py310}')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these redundant with the previous line?

Comment on lines +71 to +77
new_envlist = re.sub(r'\bpy38\b,?\s*', '', new_envlist)
new_envlist = re.sub(r'\bpy39\b,?\s*', '', new_envlist)
new_envlist = re.sub(r'\{py38\},?\s*', '', new_envlist)
new_envlist = re.sub(r'\{py39\},?\s*', '', new_envlist)
# Clean up any trailing commas or whitespace
new_envlist = re.sub(r',\s*$', '', new_envlist)
new_envlist = re.sub(r',\s*\n', '\n', new_envlist)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this do the job?

Suggested change
new_envlist = re.sub(r'\bpy38\b,?\s*', '', new_envlist)
new_envlist = re.sub(r'\bpy39\b,?\s*', '', new_envlist)
new_envlist = re.sub(r'\{py38\},?\s*', '', new_envlist)
new_envlist = re.sub(r'\{py39\},?\s*', '', new_envlist)
# Clean up any trailing commas or whitespace
new_envlist = re.sub(r',\s*$', '', new_envlist)
new_envlist = re.sub(r',\s*\n', '\n', new_envlist)
envs = [e for e in envlist.split(',') if not re.search(r'py3[89]', e)]
new_envlist = ','.join(envs)

Comment on lines +435 to +440
try:
with open(tox_config, 'rb') as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError:
print(f' ⚠ Warning: Could not parse {tox_config.name}')
return False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we just crash if the charm has a tox.toml that isn't valid TOML?

Suggested change
try:
with open(tox_config, 'rb') as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError:
print(f' ⚠ Warning: Could not parse {tox_config.name}')
return False
data = tomllib.loads(tox_config.read_text())

Comment on lines +669 to +670
req_files = list(args.charm_root.glob('*requirements*.txt'))
if req_files:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
req_files = list(args.charm_root.glob('*requirements*.txt'))
if req_files:
if list(args.charm_root.glob('*requirements*.txt')):

Comment on lines +662 to +664
# Track whether we successfully updated any dependencies
updated = False

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about moving updated = False to the else branch?

Comment on lines +294 to +297
if '\n' in allowlist:
config.set(section, 'allowlist_externals', allowlist + '\n pip')
else:
config.set(section, 'allowlist_externals', allowlist + '\n pip')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we do in each branch looks identical to me.

def add_tox_pip_commands_ini(
tox_ini_path: Path, section: str, ops_wheel: str, ops_scenario_wheel: str, use_uv_pip: bool
) -> None:
"""Add pip to allowlist_externals and commands_post to force-reinstall ops wheels (INI format).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this approach. Won't commands_post install our wheels after the commands (tests) have run? Should we be updating commands_pre instead?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants