Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
98 changes: 98 additions & 0 deletions testing/src/scenario/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
import copy
import dataclasses
import os
import sys
import tempfile
import threading
import typing
import warnings
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
Expand Down Expand Up @@ -40,6 +43,74 @@
RUNTIME_MODULE = Path(__file__).parent


_CWD_AUDIT_STATE = threading.local()


def _cwd_audit_hook(event: str, args: tuple[object, ...]) -> None:
"""Audit hook that warns when charm code reads relative paths during an event.

Under Juju, the working directory at hook start is the charm root. Scenario
historically left it as wherever pytest ran, so relative reads silently
resolve against the test directory. This hook flags any relative path
access that would resolve differently between the two, so charms can be
updated before the default flips.
"""
state = getattr(_CWD_AUDIT_STATE, 'current', None)
if state is None:
return
if event not in ('open', 'os.listdir', 'os.scandir'):

@tromai tromai Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm curious about the criteria for selecting this set of events. I assume it came from the 8 charms which depends on Scenario's 'unexpected' dir behavior ? If that's true, it's all good for me, as I don't think we should include more events.

return
if state.get('reentrant'):
return
path = args[0] if args else None
if path is None or isinstance(path, int):
return
Comment on lines +66 to +67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm curious in which case can path be an int.
I am thinking of the argument buffering to open, is that correct?

try:
path_str = os.fspath(path)
except TypeError:
return
if isinstance(path_str, bytes):
try:
path_str = path_str.decode()
except UnicodeDecodeError:
return
if not path_str or os.path.isabs(path_str):
return
if path_str in state['warned']:
return
state['reentrant'] = True

@tromai tromai Jun 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just to confirm, reentrant is used to prevent infinite recursion from this function because we do file operations included in the warning event ('open', 'os.listdir', 'os.scandir')?

If that is true, may I suggest having a small comment describing the purpose here?

try:
old_path = os.path.normpath(os.path.join(state['test_cwd'], path_str))
new_path = os.path.normpath(os.path.join(state['charm_root'], path_str))
if old_path == new_path:
return
if not (os.path.exists(old_path) or os.path.exists(new_path)):
return
state['warned'].add(path_str)
warnings.warn(
f'Scenario charm code accessed relative path {path_str!r} during '
f'event dispatch. Under Juju the working directory is the charm '
f'root, but Scenario currently keeps the test runner working '
f'directory; in a future major release Scenario will chdir to '
f'the charm root to match Juju. Set '
f'SCENARIO_CHDIR_TO_CHARM_ROOT=1 to opt in to the new behaviour '
f'now, or use self.framework.charm_dir to make the lookup '
f'explicit.',
DeprecationWarning,
stacklevel=3,
)
finally:
state['reentrant'] = False


sys.addaudithook(_cwd_audit_hook)


def _chdir_to_charm_root_opt_in() -> bool:
value = os.getenv('SCENARIO_CHDIR_TO_CHARM_ROOT', '')
return value.lower() in ('true', '1', 'yes') or (value.isdigit() and int(value) != 0)


class Runtime(Generic[CharmType]):
"""Charm runtime wrapper.

Expand Down Expand Up @@ -321,6 +392,29 @@ def exec(
previous_env = os.environ.copy()
os.environ.update(env)

# In Juju, the current working directory is the charm root when a
# hook starts. Charms (and libraries) rely on this, for example to
# read files relative to it. Scenario does not yet match that
# behaviour by default, to avoid breaking existing tests that
# depend on the test runner working directory. Set
# SCENARIO_CHDIR_TO_CHARM_ROOT=1 to opt in to the Juju behaviour;
# otherwise, an audit hook emits a DeprecationWarning the first
# time a relative path is accessed that would resolve differently
# under the new behaviour.
previous_cwd = os.getcwd()
chdir_to_charm_root = _chdir_to_charm_root_opt_in()
if chdir_to_charm_root:
os.chdir(temporary_charm_root)
audit_state = None
else:
audit_state = {
'test_cwd': previous_cwd,
'charm_root': str(temporary_charm_root),
'warned': set(),
'reentrant': False,
}
_CWD_AUDIT_STATE.current = audit_state

logger.info(' - entering ops.main (mocked)')
from ._ops_main_mock import Ops

Expand Down Expand Up @@ -364,6 +458,10 @@ def exec(
if key not in previous_env:
del os.environ[key]
os.environ.update(previous_env)
if chdir_to_charm_root:
os.chdir(previous_cwd)
else:
_CWD_AUDIT_STATE.current = None
logger.info(' - exited ops.main')

logger.info('event dispatched. done.')
Expand Down
78 changes: 78 additions & 0 deletions testing/tests/test_e2e/test_vroot.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from __future__ import annotations

import os
import tempfile
import warnings
from collections.abc import Generator, Mapping
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -58,6 +60,82 @@ def test_charm_virtual_root(charm_virtual_root: Path):
assert out.unit_status == ActiveStatus('hello world')


class CwdCharm(CharmBase):
META: Mapping[str, Any] = {'name': 'my-charm'}

def __init__(self, framework: Framework):
super().__init__(framework)
self.unit.status = ActiveStatus(os.getcwd())


def test_cwd_unchanged_by_default():
cwd_before = os.getcwd()
ctx = Context(CwdCharm, meta=dict(CwdCharm.META))
with ctx(ctx.on.start(), State()) as mgr:
mgr.run()
# By default, Scenario leaves the test runner working directory in
# place rather than chdir'ing to the charm root.
assert mgr.charm.unit.status.message == cwd_before
assert os.getcwd() == cwd_before


def test_cwd_is_charm_root_when_opted_in(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('SCENARIO_CHDIR_TO_CHARM_ROOT', '1')
cwd_before = os.getcwd()
ctx = Context(CwdCharm, meta=dict(CwdCharm.META))
with ctx(ctx.on.start(), State()) as mgr:
mgr.run()
# With the opt-in env var, cwd matches charm_dir to match Juju.
assert mgr.charm.unit.status.message == str(mgr.charm.framework.charm_dir)
# The original working directory is restored after the event is handled.
assert os.getcwd() == cwd_before


class RelativeOpenCharm(CharmBase):
META: Mapping[str, Any] = {'name': 'my-charm'}
REL_PATH = 'metadata.yaml'

def __init__(self, framework: Framework):
super().__init__(framework)
try:
with open(self.REL_PATH):
pass
except OSError:
pass


def test_relative_path_open_emits_deprecation_warning(
charm_virtual_root: Path, monkeypatch: pytest.MonkeyPatch
):
# Ensure the env var is not set so we exercise the default (warn) path.
monkeypatch.delenv('SCENARIO_CHDIR_TO_CHARM_ROOT', raising=False)
# The relative path the charm opens exists at charm_root but not at the
# test cwd, so behaviour would differ between current and future defaults.
(charm_virtual_root / RelativeOpenCharm.REL_PATH).write_text('name: my-charm\n')
ctx = Context(
RelativeOpenCharm,
meta=dict(RelativeOpenCharm.META),
charm_root=charm_virtual_root,
)
with pytest.warns(DeprecationWarning, match='SCENARIO_CHDIR_TO_CHARM_ROOT'):
ctx.run(ctx.on.start(), State())


def test_relative_path_open_no_warning_when_opted_in(
charm_virtual_root: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setenv('SCENARIO_CHDIR_TO_CHARM_ROOT', '1')
(charm_virtual_root / RelativeOpenCharm.REL_PATH).write_text('name: my-charm\n')
ctx = Context(
RelativeOpenCharm,
meta=dict(RelativeOpenCharm.META),
charm_root=charm_virtual_root,
)
with warnings.catch_warnings():
warnings.simplefilter('error', DeprecationWarning)
ctx.run(ctx.on.start(), State())


def test_charm_virtual_root_cleanup_if_exists(charm_virtual_root: Path):
meta_file = charm_virtual_root / 'metadata.yaml'
raw_ori_meta = yaml.safe_dump({'name': 'karl'})
Expand Down
Loading