Skip to content

Commit eba4d48

Browse files
author
Adam Dyess
authored
Convert project to build with uv (#148)
* Convert project to build with uv * Pin back to pytest_async<0.23 * Remove ubuntu 20.04 tests * bump setuptools * publish on tags starting with 'v' * Set fetch-tags
1 parent 92fa556 commit eba4d48

12 files changed

Lines changed: 2724 additions & 182 deletions

File tree

.github/workflows/publish.yaml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
2+
3+
on: push
4+
5+
jobs:
6+
build:
7+
name: Build distribution 📦
8+
runs-on: ubuntu-latest
9+
10+
steps:
11+
- uses: actions/checkout@v4
12+
with:
13+
fetch-depth: 0
14+
persist-credentials: false
15+
- name: Set up Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.12"
19+
- name: Install tox
20+
run: |-
21+
pip install tox-uv
22+
- name: Build a binary wheel and a source tarball
23+
run: |-
24+
tox -e build
25+
- name: Store the distribution packages
26+
uses: actions/upload-artifact@v4
27+
with:
28+
name: python-package-distributions
29+
path: dist/
30+
31+
publish-to-pypi:
32+
name: >-
33+
Publish Python 🐍 distribution 📦 to PyPI
34+
if: startsWith(github.ref, 'refs/tags/v') # only publish to PyPI on tag pushes
35+
needs:
36+
- build
37+
runs-on: ubuntu-latest
38+
environment:
39+
name: pypi
40+
url: https://pypi.org/p/pytest-operator
41+
permissions:
42+
id-token: write # IMPORTANT: mandatory for trusted publishing
43+
steps:
44+
- name: Download all the dists
45+
uses: actions/download-artifact@v4
46+
with:
47+
name: python-package-distributions
48+
path: dist/
49+
- name: Publish distribution 📦 to PyPI
50+
uses: pypa/gh-action-pypi-publish@release/v1
51+
with:
52+
password: ${{ secrets.PYPI_TOKEN }}

.github/workflows/tests.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jobs:
1414
name: Lint Unit
1515
uses: charmed-kubernetes/workflows/.github/workflows/lint-unit.yaml@main
1616
with:
17+
with-uv: true
1718
python: "['3.8', '3.9', '3.10', '3.11', '3.12']"
1819
needs:
1920
- call-inclusive-naming-check
@@ -64,7 +65,7 @@ jobs:
6465
timeout-minutes: 40
6566
strategy:
6667
matrix:
67-
ubuntu: ['20.04', '22.04', '24.04']
68+
ubuntu: ['22.04', '24.04']
6869
juju: ['3.1', '3']
6970
steps:
7071
- name: Check out code

lint-requirements.txt

Lines changed: 0 additions & 1 deletion
This file was deleted.

pyproject.toml

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
[build-system]
2+
requires = ["setuptools>=70.3.0", "setuptools_scm"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[tool.setuptools_scm]
6+
version_scheme = "guess-next-dev"
7+
local_scheme = "node-and-date"
8+
9+
[project]
10+
name = "pytest-operator"
11+
dynamic = ["version"]
12+
description = "Fixtures for Charmed Operators"
13+
readme = "README.md"
14+
requires-python = ">=3.8"
15+
license = { text = "Apache" }
16+
authors = [
17+
{name="Adam Dyess", email="adam.dyess@canonical.com"},
18+
{name="Mateo Florido", email="mateo.florido@canonical.com"}
19+
]
20+
dependencies = [
21+
"ipdb",
22+
"pytest",
23+
"pytest-asyncio<0.23",
24+
"pyyaml",
25+
"juju",
26+
"jinja2",
27+
]
28+
keywords = [
29+
"pytest",
30+
"py.test",
31+
"operators",
32+
"ops",
33+
]
34+
classifiers = [
35+
"License :: OSI Approved :: Apache Software License",
36+
"Programming Language :: Python :: 3",
37+
"Programming Language :: Python :: 3.8",
38+
"Programming Language :: Python :: 3.9",
39+
"Programming Language :: Python :: 3.10",
40+
"Programming Language :: Python :: 3.11",
41+
"Programming Language :: Python :: 3.12"
42+
]
43+
44+
[project.entry-points.pytest11]
45+
pytest-operator = "pytest_operator.plugin"
46+
47+
48+
[project.urls]
49+
"Homepage" = "https://github.com/charmed-kubernetes/pytest-operator"
50+
"Bug Tracker" = "https://github.com/charmed-kubernetes/pytest-operator/issues"
51+
52+
[tool.isort]
53+
line_length = 88
54+
profile = "black"
55+
56+
[tool.mypy]
57+
explicit_package_bases = true
58+
namespace_packages = true
59+
60+
[tool.pytest.ini_options]
61+
asyncio_default_fixture_loop_scope = "module"
62+
63+
[tool.setuptools]
64+
zip-safe = true
65+
66+
[tool.setuptools.packages.find]
67+
namespaces = true
68+
69+
[tool.ruff]
70+
line-length = 88
71+
extend-exclude = ["__pycache__", "*.egg_info"]
72+
73+
[[tool.mypy.overrides]]
74+
module = [
75+
"charms",
76+
"charms.reactive",
77+
"ipdb",
78+
"kubernetes.*",
79+
]
80+
ignore_missing_imports = true
81+
82+
[dependency-groups]
83+
format = ["ruff"]
84+
lint = [
85+
"mypy",
86+
"types-PyYAML",
87+
"types-setuptools",
88+
"ops",
89+
{include-group = "format"},
90+
{include-group = "unit"},
91+
]
92+
publish = [
93+
"twine"
94+
]
95+
unit = [
96+
"pytest>=8.3.4",
97+
"pytest-cov>=5.0.0",
98+
"pytest-html",
99+
]

pytest_operator/plugin.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from typing import (
2525
Any,
2626
Dict,
27+
Generic,
2728
Generator,
2829
Iterable,
2930
List,
@@ -69,8 +70,7 @@ def pytest_addoption(parser: Parser):
6970
parser.addoption(
7071
"--controller",
7172
action="store",
72-
help="Juju controller to use; if not provided, "
73-
"will use the current controller",
73+
help="Juju controller to use; if not provided, will use the current controller",
7474
)
7575
parser.addoption(
7676
"--model-alias",
@@ -172,10 +172,9 @@ def pytest_configure(config: Config):
172172
config.addinivalue_line("markers", "abort_on_fail")
173173
config.addinivalue_line("markers", "skip_if_deployed")
174174

175-
if config.option.basetemp is None:
176-
tox_dir = os.environ.get("TOX_ENV_DIR")
177-
if tox_dir:
178-
config.option.basetemp = Path(tox_dir) / "tmp/pytest"
175+
if tox_dir := os.environ.get("TOX_ENV_DIR"):
176+
config.option.basetemp = Path(tox_dir) / "tmp/pytest"
177+
log.info("Using basetemp: %s", config.option.basetemp)
179178

180179

181180
def pytest_runtest_setup(item):
@@ -211,16 +210,16 @@ def event_loop():
211210

212211

213212
# Plugin load order can't be set, replace asyncio directly
214-
pytest_asyncio.plugin.event_loop = event_loop
213+
pytest_asyncio.plugin.event_loop = event_loop # type: ignore
215214

216215

217216
def pytest_collection_modifyitems(session, config, items):
218-
"""Automatically apply the "asyncio" marker to any async test items."""
217+
"""Automatically apply the "pytest.mark.asyncio" marker to any async testitems."""
219218
for item in items:
220219
is_async = inspect.iscoroutinefunction(getattr(item, "function", None))
221220
has_marker = item.get_closest_marker("asyncio")
222221
if is_async and not has_marker:
223-
item.add_marker("asyncio")
222+
item.add_marker(pytest.mark.asyncio)
224223

225224

226225
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
@@ -442,12 +441,12 @@ def _connect_kwds(request) -> Dict[str, Any]:
442441

443442

444443
@dataclasses.dataclass
445-
class ModelState:
444+
class ModelState(Generic[Timeout]):
446445
model: Model
447446
keep: bool
448447
destroy_storage: bool
449448
controller_name: str
450-
cloud_name: Optional[str]
449+
cloud_name: str
451450
model_name: str
452451
config: Optional[dict] = None
453452
tmp_path: Optional[Path] = None
@@ -459,7 +458,7 @@ def full_name(self) -> str:
459458

460459

461460
@dataclasses.dataclass
462-
class CloudState:
461+
class CloudState(Generic[Timeout]):
463462
cloud_name: str
464463
models: List[str] = dataclasses.field(default_factory=list)
465464
timeout: Optional[Timeout] = None
@@ -520,7 +519,7 @@ def juju_download_args(self):
520519
if field.default is not dataclasses.MISSING
521520
]
522521

523-
def __init__(self, request, tmp_path_factory):
522+
def __init__(self, request, tmp_path_factory) -> None:
524523
self.request = request
525524
self._tmp_path_factory = tmp_path_factory
526525
self._global_tmp_path = None
@@ -558,7 +557,7 @@ def __init__(self, request, tmp_path_factory):
558557

559558
# maintains a set of all models connected by this fixture
560559
# use an OrderedDict so that the first model made is destroyed last.
561-
self._current_alias = None
560+
self._current_alias: Optional[str] = None
562561
self._models: MutableMapping[str, ModelState] = OrderedDict()
563562
self._clouds: MutableMapping[str, CloudState] = OrderedDict()
564563

@@ -575,9 +574,18 @@ def model_context(self, alias: str) -> Generator[Model, None, None]:
575574
# if the there's a failure after yielding, don't fail to
576575
# switch back to the prior alias but still raise whatever
577576
# error condition occurred through the context
578-
self._switch(prior, raise_not_found=False)
577+
if isinstance(prior, str):
578+
self._switch(prior, raise_not_found=False)
579+
580+
@overload
581+
def _switch(self, alias: str, raise_not_found: Literal[True] = True) -> Model: ...
579582

580-
def _switch(self, alias: str, raise_not_found=True) -> Model:
583+
@overload
584+
def _switch(
585+
self, alias: str, raise_not_found: Literal[False] = False
586+
) -> Optional[Model]: ...
587+
588+
def _switch(self, alias: str, raise_not_found=True) -> Optional[Model]:
581589
if alias in self._models:
582590
self._current_alias = alias
583591
elif not raise_not_found:
@@ -777,6 +785,8 @@ async def _model_exists(self, model_name: str) -> bool:
777785
"""
778786
returns True when the model_name exists in the model.
779787
"""
788+
if not self._controller:
789+
return False
780790
all_models = await self._controller.list_models()
781791
return model_name in all_models
782792

@@ -790,13 +800,16 @@ async def _connect_to_model(
790800
"""
791801
model = Model()
792802
state = ModelState(
793-
model, keep, destroy_storage, controller_name, None, model_name
803+
model, keep, destroy_storage, controller_name, "", model_name
794804
)
795805
log.info(
796806
"Connecting to existing model %s on unspecified cloud", state.full_name
797807
)
798808
await model.connect(state.full_name, **connect_kwargs)
799809
state.config = await model.get_config()
810+
controller = await model.get_controller()
811+
state.cloud_name = await controller.get_cloud()
812+
800813
return state
801814

802815
@staticmethod
@@ -920,13 +933,13 @@ async def track_model(
920933
**self._juju_connect_kwds,
921934
)
922935
else:
923-
cloud_name = cloud_name or self.cloud_name
936+
cloud_name = cloud_name or self.cloud_name or ""
924937
model_name = model_name or self._generate_name(kind="model")
925938
model_state = await self._add_model(
926939
cloud_name, model_name, keep_val, destroy_storage_val, **kwargs
927940
)
928941
self._models[alias] = model_state
929-
if ops_cloud := self._clouds.get(cloud_name):
942+
if ops_cloud := self._clouds.get(model_state.cloud_name):
930943
ops_cloud.models.append(alias)
931944
return model_state.model
932945

@@ -986,7 +999,7 @@ async def forget_model(
986999
if not alias:
9871000
alias = self.current_alias
9881001

989-
if alias not in self.models:
1002+
if not alias or alias not in self.models:
9901003
raise ModelNotFoundError(f"{alias} not found")
9911004

9921005
model_state: ModelState = self._models[alias]
@@ -1127,7 +1140,7 @@ async def build_charm(
11271140
async def build_charm(
11281141
self,
11291142
charm_path,
1130-
bases_index: int = None,
1143+
bases_index: Optional[int] = None,
11311144
verbosity: Optional[
11321145
Literal["quiet", "brief", "verbose", "debug", "trace"]
11331146
] = None,
@@ -1736,6 +1749,9 @@ async def add_k8s(
17361749
juju_cloud_config["operator-storage"] = storage_class
17371750

17381751
controller = self._controller
1752+
if not controller:
1753+
raise RuntimeError("No controller currently set.")
1754+
17391755
cloud_name = cloud_name or self._generate_name("k8s-cloud")
17401756
log.info(f"Adding k8s cloud {cloud_name}")
17411757

@@ -1790,5 +1806,6 @@ async def forget_cloud(self, cloud_name: str):
17901806
for model in reversed(self._clouds[cloud_name].models):
17911807
await self.forget_model(model, destroy_storage=True)
17921808
log.info(f"Forgetting cloud: {cloud_name}...")
1793-
await self._controller.remove_cloud(cloud_name)
1809+
if self._controller:
1810+
await self._controller.remove_cloud(cloud_name)
17941811
del self._clouds[cloud_name]

0 commit comments

Comments
 (0)