Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fc02f86
Rename test class for consistency
nathanjmcdougall Sep 22, 2025
cf2f5e3
Add comment regarding use of default docker image in Bitbucket pipelines
nathanjmcdougall Sep 22, 2025
f5fb08b
Add a backend-agnostic `get_supported_major_python_versions` in a new…
nathanjmcdougall Sep 22, 2025
03ffe6b
Continue implementing the `backend=none` behaviour for bitbucket pipe…
nathanjmcdougall Oct 7, 2025
371d77a
Fix typo
nathanjmcdougall Oct 7, 2025
c4bdf52
Don't include patch version of Python in Bitbucket pipelines image wh…
nathanjmcdougall Oct 7, 2025
b676a1d
Fix capitalization of `uv` in the test suite
nathanjmcdougall Oct 7, 2025
06e2f92
Add clarifying comment to test docstring
nathanjmcdougall Oct 7, 2025
2987d85
Fix tests regarding auto-backend-only messages
nathanjmcdougall Oct 7, 2025
6645710
Fix no backend test for bitbucket pipelines integration
nathanjmcdougall Oct 7, 2025
eb06c0e
Fix interface test messages for bitbucket pipelines integration
nathanjmcdougall Oct 7, 2025
1c164ea
Merge branch 'main' into 909-make-bitbucket-ci-config-backend-agnostic
nathanjmcdougall Oct 8, 2025
d8dea9b
Merge branch 'main' into 909-make-bitbucket-ci-config-backend-agnostic
nathanjmcdougall Oct 10, 2025
e84a2f4
Monkey-patch the maxmimal bitbucket pieplines config to use Python 3.10
nathanjmcdougall Oct 10, 2025
fbbdecf
Monkey-patch the maxmimal bitbucket pieplines config to use Python 3.10
nathanjmcdougall Oct 10, 2025
20a964e
Explicitly check whether pytest.ini exists in failing test
nathanjmcdougall Oct 10, 2025
9fa5b13
Refactor tests to reflect new `pytest.ini` precedence
nathanjmcdougall Oct 10, 2025
93e1a82
Merge branch 'main' into 909-make-bitbucket-ci-config-backend-agnostic
nathanjmcdougall Oct 10, 2025
ab7f334
Introduce wrapper to ensure `CliRunner.invoke` is called with `catch_…
nathanjmcdougall Oct 10, 2025
4aeae16
Add test for no-backend via auto messages for bitbucket pipeline step…
nathanjmcdougall Oct 10, 2025
71290c8
Ensure Import Linter is added via `use_ci_bitbucket`
nathanjmcdougall Oct 13, 2025
c67d879
Fix none-backend maximual bitbucket pipelines test expectations
nathanjmcdougall Oct 13, 2025
ccc827f
Merge branch 'main' into 909-make-bitbucket-ci-config-backend-agnostic
nathanjmcdougall Oct 13, 2025
b9bed93
Fix deptry config in tests\usethis\_ui\interface\maximal_bitbucket_pi…
nathanjmcdougall Oct 13, 2025
9d0dfad
Merge branch '909-make-bitbucket-ci-config-backend-agnostic' of https…
nathanjmcdougall Oct 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ dynamic = [
"version",
]
dependencies = [
"click>=8.0.0",
"configupdater>=3.2",
"grimp>=2.5.0",
"mergedeep>=1.3.4",
Expand All @@ -65,6 +64,7 @@ dev = [
"uv>=0.9.1",
]
test = [
"click>=8.1.8",
"coverage[toml]>=7.7.1",
"gitpython>=3.1.44",
"pytest>=8.3.5",
Expand Down Expand Up @@ -121,13 +121,16 @@ lint.select = [
"S",
"SIM",
"TC",
"TID",
"UP",
]
lint.ignore = [ "PLR2004", "S101", "SIM108" ]
lint.per-file-ignores."!tests/**/*.py" = [ "ARG002" ]
lint.per-file-ignores."tests/**/*.py" = [ "D", "INP", "S603", "TC" ]
lint.flake8-bugbear.extend-immutable-calls = [ "typer.Argument", "typer.Option" ]

lint.flake8-tidy-imports.banned-api."typer.testing.CliRunner".msg = "Use `usethis._test.CliRunner` instead of `typer.CliRunner`."

lint.flake8-type-checking.quote-annotations = true
lint.flake8-type-checking.runtime-evaluated-base-classes = [ "pydantic.BaseModel" ]
lint.flake8-type-checking.strict = true
Expand All @@ -136,9 +139,6 @@ lint.pydocstyle.convention = "google"
[tool.codespell]
ignore-words-list = [ "edn" ]

[tool.deptry.per_rule_ignores]
DEP002 = [ "click" ] # https://github.com/usethis-python/usethis-python/issues/623

[tool.pyproject-fmt]
keep_full_version = true

Expand Down Expand Up @@ -258,6 +258,7 @@ name = "usethis._integrations"
type = "layers"
layers = [
"ci | pre_commit",
"environ",
"backend | mkdocs | pytest | pydantic | sonarqube",
"project | python",
"file",
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ click==8.1.8 \
# import-linter
# mkdocs
# typer
# usethis
colorama==0.4.6 \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
Expand Down
15 changes: 8 additions & 7 deletions src/usethis/_core/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,14 @@ def use_requirements_txt(*, remove: bool = False, how: bool = False) -> None:
change_toml=False,
)
elif backend is BackendEnum.none:
# Simply dump the dependencies list to requirements.txt as-
info_print(
"Generating 'requirements.txt' with un-pinned, abstract dependencies."
)
info_print(
"Consider installing 'uv' for pinned, cross-platform, full requirements files."
)
# Simply dump the dependencies list to requirements.txt
if usethis_config.backend is BackendEnum.auto:
info_print(
"Generating 'requirements.txt' with un-pinned, abstract dependencies."
)
info_print(
"Consider installing 'uv' for pinned, cross-platform, full requirements files."
)
tick_print("Writing 'requirements.txt'.")
with open(path, "w", encoding="utf-8") as f:
f.write("-e .\n")
Expand Down
16 changes: 12 additions & 4 deletions src/usethis/_integrations/ci/bitbucket/anchor.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from __future__ import annotations

from typing import Literal, TypeAlias
from typing import Any, Literal, TypeAlias

from pydantic import BaseModel
from ruamel.yaml.scalarstring import LiteralScalarString

# N.B. at the point where we support more than one script item, we should create a
# canonical sort order for them and enforce it when we add them to the pipeline.
ScriptItemName: TypeAlias = Literal["install-uv"]
ScriptItemName: TypeAlias = Literal["install-uv", "ensure-venv"]


class ScriptItemAnchor(BaseModel):
name: ScriptItemName


def anchor_name_from_script_item(item: Any) -> str | None:
"""Extract the anchor name from a script item, if it has one."""
if isinstance(item, LiteralScalarString):
anchor = item.yaml_anchor()
if anchor is not None:
return anchor.value
return None
2 changes: 2 additions & 0 deletions src/usethis/_integrations/ci/bitbucket/io_.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ def edit_bitbucket_pipelines_yaml() -> Generator[

if not path.exists():
tick_print(f"Writing '{name}'.")
# N.B. where necessary, we can opt to use a different image from this default
# on a per-step basis, so this is safe even when we want to use Python images.
path.write_text("image: atlassian/default-image:3", encoding="utf-8")
guess_indent = False
else:
Expand Down
99 changes: 75 additions & 24 deletions src/usethis/_integrations/ci/bitbucket/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
import usethis._pipeweld.func
from usethis._config import usethis_config
from usethis._console import box_print, tick_print
from usethis._integrations.backend.uv.python import (
get_supported_uv_major_python_versions,
from usethis._integrations.backend.dispatch import get_backend
from usethis._integrations.ci.bitbucket.anchor import (
ScriptItemAnchor,
anchor_name_from_script_item,
)
from usethis._integrations.ci.bitbucket.anchor import ScriptItemAnchor
from usethis._integrations.ci.bitbucket.cache import _add_caches_via_doc, remove_cache
from usethis._integrations.ci.bitbucket.dump import bitbucket_fancy_dump
from usethis._integrations.ci.bitbucket.errors import UnexpectedImportPipelineError
Expand All @@ -37,7 +38,9 @@
StepItem,
)
from usethis._integrations.ci.bitbucket.schema_utils import step1tostep
from usethis._integrations.environ.python import get_supported_major_python_versions
from usethis._integrations.file.yaml.update import update_ruamel_yaml_map
from usethis._types.backend import BackendEnum

if TYPE_CHECKING:
from ruamel.yaml.anchor import Anchor
Expand All @@ -60,7 +63,11 @@
source $HOME/.local/bin/env
export UV_LINK_MODE=copy
uv --version
""")
"""),
"ensure-venv": LiteralScalarString("""\
python -m venv .venv
source .venv/bin/activate
"""),
}
for name, script_item in _SCRIPT_ITEM_LOOKUP.items():
script_item.yaml_set_anchor(value=name, always_dump=True)
Expand Down Expand Up @@ -125,6 +132,7 @@ def _add_step_in_default_via_doc(

# If our anchor doesn't have a definition yet, we need to add it.
if script_item.name not in defined_script_item_by_name:
script_item_name = script_item.name
try:
script_item = _SCRIPT_ITEM_LOOKUP[script_item.name]
except KeyError:
Expand All @@ -134,17 +142,19 @@ def _add_step_in_default_via_doc(
if config.definitions is None:
config.definitions = Definitions()

script_items = config.definitions.script_items
existing_script_items = config.definitions.script_items

if script_items is None:
script_items = CommentedSeq()
config.definitions.script_items = script_items
if existing_script_items is None:
existing_script_items = CommentedSeq()
config.definitions.script_items = existing_script_items

# N.B. Once we support multiple different types of script items, we will
# probably want to enforce a canonical order rather than just append.
# See also anchor.py.
script_items.append(script_item)
script_items = CommentedSeq(script_items)
# Insert script item in canonical order based on _SCRIPT_ITEM_LOOKUP
insertion_index = _get_script_item_insertion_index(
script_item_name=script_item_name,
doc=doc,
)
existing_script_items.insert(insertion_index, script_item)
existing_script_items = CommentedSeq(existing_script_items)
else:
# Otherwise, if the anchor is already defined, we need to use the
# reference
Expand All @@ -158,7 +168,7 @@ def _add_step_in_default_via_doc(
# N.B. Currently, we are not accounting for parallelism, whereas all these steps
# could be parallel potentially.
# See https://github.com/usethis-python/usethis-python/issues/149
maj_versions = get_supported_uv_major_python_versions()
maj_versions = get_supported_major_python_versions()
step_order = [
"Run pre-commit",
# For these tools, sync them with the pre-commit removal logic
Expand Down Expand Up @@ -186,6 +196,33 @@ def _add_step_in_default_via_doc(
)


def _get_script_item_insertion_index(
*, script_item_name: ScriptItemName, doc: BitbucketPipelinesYAMLDocument
) -> int:
"""Get the correct insertion index for a script item to maintain canonical order."""
# Check if we have existing script items in the raw YAML content
if not (
dict(doc.content).get("definitions")
and dict(doc.content["definitions"]).get("script_items")
):
return 0

existing_script_items = doc.content["definitions"]["script_items"]
canonical_order = list(_SCRIPT_ITEM_LOOKUP.keys())

for i, existing_item in enumerate(existing_script_items):
existing_name = anchor_name_from_script_item(existing_item)
if (
existing_name is not None
and existing_name in canonical_order
and canonical_order.index(script_item_name)
< canonical_order.index(existing_name)
):
return i

return len(existing_script_items) # Default to end


def remove_bitbucket_step_from_default(step: Step) -> None:
"""Remove a step from the default pipeline in the Bitbucket Pipelines configuration.

Expand Down Expand Up @@ -437,16 +474,30 @@ def add_placeholder_step_in_default(report_placeholder: bool = True) -> None:


def _get_placeholder_step() -> Step:
return Step(
name=_PLACEHOLDER_NAME,
script=Script(
[
ScriptItemAnchor(name="install-uv"),
"echo 'Hello, world!'",
]
),
caches=["uv"],
)
backend = get_backend()

if backend is BackendEnum.uv:
return Step(
name=_PLACEHOLDER_NAME,
script=Script(
[
ScriptItemAnchor(name="install-uv"),
"echo 'Hello, world!'",
]
),
caches=["uv"],
)
elif backend is BackendEnum.none:
return Step(
name=_PLACEHOLDER_NAME,
script=Script(
[
"echo 'Hello, world!'",
]
),
)
else:
assert_never(backend)


def get_defined_script_items_via_doc(
Expand Down
Empty file.
23 changes: 23 additions & 0 deletions src/usethis/_integrations/environ/python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from __future__ import annotations

from typing_extensions import assert_never

from usethis._integrations.backend.dispatch import get_backend
from usethis._integrations.backend.uv.python import (
get_supported_uv_major_python_versions,
)
from usethis._integrations.python.version import get_python_major_version
from usethis._types.backend import BackendEnum


def get_supported_major_python_versions() -> list[int]:
backend = get_backend()

if backend is BackendEnum.uv:
versions = get_supported_uv_major_python_versions()
elif backend is BackendEnum.none:
versions = [get_python_major_version()]
else:
assert_never(backend)

return versions
5 changes: 5 additions & 0 deletions src/usethis/_integrations/python/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ def get_python_version() -> str:
return _get_python_version()


def get_python_major_version() -> int:
"""Get the major version of Python."""
return extract_major_version(get_python_version())


def extract_major_version(version: str) -> int:
"""Extract the major version from a version string."""
return int(version.split(".")[1])
54 changes: 52 additions & 2 deletions src/usethis/_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
import socket
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING
from typing import IO, TYPE_CHECKING, Any

from typer.testing import CliRunner as TyperCliRunner # noqa: TID251

from usethis._config import usethis_config

if TYPE_CHECKING:
from collections.abc import Generator
from collections.abc import Generator, Mapping, Sequence

from click.testing import Result
from typer import Typer


@contextmanager
Expand All @@ -35,3 +40,48 @@ def is_offline() -> bool:
else:
s.close()
return False


class CliRunner(TyperCliRunner):
def invoke_safe(
self,
app: Typer,
args: str | Sequence[str] | None = None,
input: bytes | str | IO[Any] | None = None, # noqa: A002
env: Mapping[str, str] | None = None,
color: bool = False,
**extra: Any,
) -> Result:
return self.invoke(
app,
args=args,
input=input,
env=env,
catch_exceptions=False,
color=color,
**extra,
)

def invoke( # noqa: PLR0913
self,
app: Typer,
args: str | Sequence[str] | None = None,
input: bytes | str | IO[Any] | None = None, # noqa: A002
env: Mapping[str, str] | None = None,
catch_exceptions: bool = True,
color: bool = False,
**extra: Any,
) -> Result:
if catch_exceptions:
msg = "`catch_exceptions=True` is forbidden in usethis tests. Use `.invoke_safe()` instead."
raise NotImplementedError(msg)

return super().invoke(
app,
args=args,
input=input,
env=env,
catch_exceptions=catch_exceptions,
color=color,
**extra,
)
Loading