Skip to content

Robustness changes#58

Merged
GavinHuttley merged 2 commits into
HuttleyLab:developfrom
GavinHuttley:develop
Jun 24, 2026
Merged

Robustness changes#58
GavinHuttley merged 2 commits into
HuttleyLab:developfrom
GavinHuttley:develop

Conversation

@GavinHuttley

@GavinHuttley GavinHuttley commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Summary by Sourcery

Improve robustness of package introspection and metadata logging, and ensure version logging never emits None values.

Bug Fixes:

  • Guard frame inspection with cleanup to avoid leaking frame references when determining package names and logging arguments.
  • Normalise missing package version metadata to an UNKNOWN sentinel instead of logging name==None.

Tests:

  • Add a regression test to verify that logging versions for modules without version information records UNKNOWN and not None.

…ce cycles

[CHANGED] get_package_name, log_args, log_versions and log_licenses retained
    references to frames from inspect.currentframe() without releasing them,
    creating reference cycles. Now del the frame and its parent.
[FIXED] _log_metadata now normalises a None value to the "UNKNOWN" sentinel.
    Version lines for unresolvable packages are logged as
    name==UNKNOWN rather than name==None
@sourcery-ai

sourcery-ai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

Improves robustness and cleanliness of logging utilities by ensuring frames are cleaned up after inspection and normalizing missing version metadata to 'UNKNOWN', with tests added to lock in the new behavior.

Sequence diagram for updated metadata logging with UNKNOWN normalization

sequenceDiagram
    participant Caller
    participant Logger
    participant get_version_for_package

    Caller->>Logger: log_versions(packages)
    Logger->>Logger: _log_metadata(packages, get_version_for_package, caller_name, label)

    loop packages_and_dependencies
        Logger->>get_version_for_package: get_version_for_package(pkg)
        get_version_for_package-->>Logger: value
        alt [value is None]
            Logger->>Logger: resolved = UNKNOWN
        else [value is not None]
            Logger->>Logger: resolved = value
        end
        Logger->>Logger: log_message(name==resolved, label)
    end
Loading

File-Level Changes

Change Details Files
Ensure temporary stack frame references are cleaned up after introspection to avoid reference cycles.
  • Wrap get_package_name frame usage in a try/finally and delete frame and parent references
  • Delete frame and parent after inspecting caller args in log_args
  • Delete frame and parent after computing caller_name in log_versions and log_licenses
src/scitrack/__init__.py
Normalize missing package version metadata to a stable sentinel and document behavior.
  • Update _log_metadata docstring to describe None version normalization to 'UNKNOWN'
  • Change metadata logging to substitute 'UNKNOWN' when a resolved value is None instead of logging 'name==None'
  • Add test to assert that log_versions logs 'UNKNOWN' for modules without a version and never '==None'
src/scitrack/__init__.py
tests/test_logging.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@GavinHuttley GavinHuttley merged commit f4d5bca into HuttleyLab:develop Jun 24, 2026
9 checks passed

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In the new test test_log_versions_unresolvable_version_logs_unknown, consider cleaning up sys.path and sys.modules['delme_nover'] in a try/finally block to avoid leaking the temporary module and path change into other tests.
  • For the new inspect.currentframe() usages where you manually del frame, parent, you might want to wrap the body in a try/finally (as you did in get_package_name) so that the frames are always cleared even if inspect.getargvalues or similar calls raise.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the new test `test_log_versions_unresolvable_version_logs_unknown`, consider cleaning up `sys.path` and `sys.modules['delme_nover']` in a `try/finally` block to avoid leaking the temporary module and path change into other tests.
- For the new `inspect.currentframe()` usages where you manually `del frame, parent`, you might want to wrap the body in a `try/finally` (as you did in `get_package_name`) so that the frames are always cleared even if `inspect.getargvalues` or similar calls raise.

## Individual Comments

### Comment 1
<location path="src/scitrack/__init__.py" line_range="552-558" />
<code_context>
         lines under ``label`` with the caller first, the rest
-        alphabetical. Lookups happen eagerly so a failed resolution aborts
-        before any line is written.
+        alphabetical. A ``None`` value is normalised to the ``"UNKNOWN"``
+        sentinel so a line is never written as ``name==None``. Lookups
+        happen eagerly so a failed resolution aborts before any line is
</code_context>
<issue_to_address>
**suggestion:** Align docstring and behavior for `None` values, especially for `caller_name`.

The new docstring matches the `packages` loop behavior, but `caller_name` is still skipped when `value_for(caller_name)` returns `None` due to the `caller_value is not None` guard. If you want all `None` values to become `name=="UNKNOWN"`, consider removing or relaxing that guard and relying solely on the `resolved = value if value is not None else "UNKNOWN"` normalization. Otherwise, if omitting the caller on `None` is intentional, please update the docstring to call out this special case so it stays accurate.

Suggested implementation:

```python
        caller_value: str | None = None
        if caller_name is not None:
            caller_value = value_for(caller_name)
            entries.append((caller_name, caller_value))

```

To fully align behavior with the docstring (all `None` values normalized to `"UNKNOWN"`), you should:
1. Remove any conditional guards of the form `if caller_value is not None:` (or equivalent) around the `entries.append` for the caller, so that `None` values are allowed through to the loop that applies `resolved = value if value is not None else "UNKNOWN"`.
2. Ensure that any other special-casing of `caller_name` that skips logging on `None` is removed, so the caller is treated consistently with package entries.
</issue_to_address>

### Comment 2
<location path="src/scitrack/__init__.py" line_range="107" />
<code_context>
+            return ""
+        return _installed_package_from_globals(parent.f_globals)
+    finally:
+        del frame, parent


</code_context>
<issue_to_address>
**suggestion (bug_risk):** Use `try`/`finally` around `inspect.currentframe()` usage to guarantee cleanup on exceptions.

In `log_args`, `log_versions`, and `log_licenses`, `del frame, parent` only runs on the happy path; any exception before it will keep the frame alive and maintain the reference cycle. To match `get_package_name` and avoid leaks in long‑lived processes, please wrap each `inspect.currentframe()` usage in a `try`/`finally` so cleanup always occurs.

Suggested implementation:

```python
    frame = inspect.currentframe()
    parent = frame.f_back if frame is not None else None
    try:
        if parent is None:
            return ""
        return _installed_package_from_globals(parent.f_globals)
    finally:
        del frame, parent

```

```python
    frame = inspect.currentframe()
    parent = frame.f_back if frame is not None else None
    try:
        if parent is None:
            return ""
        return _installed_package_from_globals(parent.f_globals)
    finally:
        del frame, parent

```

From your comment, `get_package_name` already uses a correct `try`/`finally` pattern. To fully implement your suggestion:
1. Locate all other usages of `inspect.currentframe()` in this file, specifically in `log_args`, `log_versions`, and `log_licenses`.
2. For each, wrap the logic that uses `frame`/`parent` in a `try` block and place `del frame, parent` in a corresponding `finally` block, mirroring the pattern used in `get_package_name` and the edits above.
3. Ensure there are no early returns or exceptions paths that bypass the `finally` block so that frame cleanup is guaranteed.
</issue_to_address>

### Comment 3
<location path="tests/test_logging.py" line_range="372-381" />
<code_context>
             assert expect in line, line


+def test_log_versions_unresolvable_version_logs_unknown(logfile):
+    # a module with no version attribute -> version None normalised to UNKNOWN
+    pyfile = TEST_ROOTDIR / "delme_nover.py"
+    pyfile.write_text("answer = 42\n")
+    sys.path.append(str(TEST_ROOTDIR))
+    import delme_nover
+
+    LOGGER = CachingLogger(create_dir=True)
+    LOGGER.log_file_path = logfile
+    LOGGER.log_versions(delme_nover)
+    LOGGER.shutdown()
+    pyfile.unlink()
+    lines = logfile.read_text().splitlines()
+    assert any("delme_nover==UNKNOWN" in line for line in lines)
+    assert not any("delme_nover==None" in line for line in lines)
+
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Clean up sys.path and sys.modules mutations in the new test to avoid cross-test interference.

This test mutates global state by appending to sys.path and importing delme_nover without cleanup, which can cause ordering-dependent failures in other tests. Please wrap the sys.path change in a try/finally and remove the path entry, and delete sys.modules['delme_nover'] after the test (guarding for KeyError) so it doesn’t leak global state.

Suggested implementation:

```python
def test_log_versions_unresolvable_version_logs_unknown(logfile):
    # a module with no version attribute -> version None normalised to UNKNOWN
    pyfile = TEST_ROOTDIR / "delme_nover.py"
    pyfile.write_text("answer = 42\n")
    module_name = "delme_nover"
    path_entry = str(TEST_ROOTDIR)
    sys.path.append(path_entry)
    try:
        delme_nover = __import__(module_name)

        LOGGER = CachingLogger(create_dir=True)
        LOGGER.log_file_path = logfile
        LOGGER.log_versions(delme_nover)
        LOGGER.shutdown()
        lines = logfile.read_text().splitlines()
        assert any("delme_nover==UNKNOWN" in line for line in lines)
        assert not any("delme_nover==None" in line for line in lines)
    finally:
        # Clean up global state and temporary file to avoid cross-test interference
        if path_entry in sys.path:
            sys.path.remove(path_entry)
        sys.modules.pop(module_name, None)
        if pyfile.exists():
            pyfile.unlink()

```

1. Ensure `import sys` is present at the top of `tests/test_logging.py`, as this test now uses `sys.path` and `sys.modules`. If it's missing, add `import sys` alongside the other imports.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/scitrack/__init__.py
Comment on lines +552 to 558
alphabetical. A ``None`` value is normalised to the ``"UNKNOWN"``
sentinel so a line is never written as ``name==None``. Lookups
happen eagerly so a failed resolution aborts before any line is
written.
"""
caller_value: str | None = None
if caller_name:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Align docstring and behavior for None values, especially for caller_name.

The new docstring matches the packages loop behavior, but caller_name is still skipped when value_for(caller_name) returns None due to the caller_value is not None guard. If you want all None values to become name=="UNKNOWN", consider removing or relaxing that guard and relying solely on the resolved = value if value is not None else "UNKNOWN" normalization. Otherwise, if omitting the caller on None is intentional, please update the docstring to call out this special case so it stays accurate.

Suggested implementation:

        caller_value: str | None = None
        if caller_name is not None:
            caller_value = value_for(caller_name)
            entries.append((caller_name, caller_value))

To fully align behavior with the docstring (all None values normalized to "UNKNOWN"), you should:

  1. Remove any conditional guards of the form if caller_value is not None: (or equivalent) around the entries.append for the caller, so that None values are allowed through to the loop that applies resolved = value if value is not None else "UNKNOWN".
  2. Ensure that any other special-casing of caller_name that skips logging on None is removed, so the caller is treated consistently with package entries.

Comment thread src/scitrack/__init__.py
return ""
return _installed_package_from_globals(parent.f_globals)
finally:
del frame, parent

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Use try/finally around inspect.currentframe() usage to guarantee cleanup on exceptions.

In log_args, log_versions, and log_licenses, del frame, parent only runs on the happy path; any exception before it will keep the frame alive and maintain the reference cycle. To match get_package_name and avoid leaks in long‑lived processes, please wrap each inspect.currentframe() usage in a try/finally so cleanup always occurs.

Suggested implementation:

    frame = inspect.currentframe()
    parent = frame.f_back if frame is not None else None
    try:
        if parent is None:
            return ""
        return _installed_package_from_globals(parent.f_globals)
    finally:
        del frame, parent
    frame = inspect.currentframe()
    parent = frame.f_back if frame is not None else None
    try:
        if parent is None:
            return ""
        return _installed_package_from_globals(parent.f_globals)
    finally:
        del frame, parent

From your comment, get_package_name already uses a correct try/finally pattern. To fully implement your suggestion:

  1. Locate all other usages of inspect.currentframe() in this file, specifically in log_args, log_versions, and log_licenses.
  2. For each, wrap the logic that uses frame/parent in a try block and place del frame, parent in a corresponding finally block, mirroring the pattern used in get_package_name and the edits above.
  3. Ensure there are no early returns or exceptions paths that bypass the finally block so that frame cleanup is guaranteed.

Comment thread tests/test_logging.py
Comment on lines +372 to +381
def test_log_versions_unresolvable_version_logs_unknown(logfile):
# a module with no version attribute -> version None normalised to UNKNOWN
pyfile = TEST_ROOTDIR / "delme_nover.py"
pyfile.write_text("answer = 42\n")
sys.path.append(str(TEST_ROOTDIR))
import delme_nover

LOGGER = CachingLogger(create_dir=True)
LOGGER.log_file_path = logfile
LOGGER.log_versions(delme_nover)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Clean up sys.path and sys.modules mutations in the new test to avoid cross-test interference.

This test mutates global state by appending to sys.path and importing delme_nover without cleanup, which can cause ordering-dependent failures in other tests. Please wrap the sys.path change in a try/finally and remove the path entry, and delete sys.modules['delme_nover'] after the test (guarding for KeyError) so it doesn’t leak global state.

Suggested implementation:

def test_log_versions_unresolvable_version_logs_unknown(logfile):
    # a module with no version attribute -> version None normalised to UNKNOWN
    pyfile = TEST_ROOTDIR / "delme_nover.py"
    pyfile.write_text("answer = 42\n")
    module_name = "delme_nover"
    path_entry = str(TEST_ROOTDIR)
    sys.path.append(path_entry)
    try:
        delme_nover = __import__(module_name)

        LOGGER = CachingLogger(create_dir=True)
        LOGGER.log_file_path = logfile
        LOGGER.log_versions(delme_nover)
        LOGGER.shutdown()
        lines = logfile.read_text().splitlines()
        assert any("delme_nover==UNKNOWN" in line for line in lines)
        assert not any("delme_nover==None" in line for line in lines)
    finally:
        # Clean up global state and temporary file to avoid cross-test interference
        if path_entry in sys.path:
            sys.path.remove(path_entry)
        sys.modules.pop(module_name, None)
        if pyfile.exists():
            pyfile.unlink()
  1. Ensure import sys is present at the top of tests/test_logging.py, as this test now uses sys.path and sys.modules. If it's missing, add import sys alongside the other imports.

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.

1 participant