Skip to content
Open
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
203 changes: 203 additions & 0 deletions docs/docs/user-guide/controlling-log-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Controlling Log Output

EasyScience uses Python's standard `logging` module for all
informational and warning messages. This gives you full control over
what output the library produces and where it goes.

## Quick Start

The most common operation is suppressing all EasyScience messages. You
can do it in one line:

```python
from easyscience import global_object

global_object.log.level = 'ERROR'
```

Or using the standard library directly:

```python
import logging

logging.getLogger('easyscience').setLevel(logging.ERROR)
```

Both forms set the package-root logger to `ERROR`, which suppresses all
`WARNING`, `INFO`, and `DEBUG` messages from EasyScience.

## Logger Hierarchy

EasyScience loggers are named hierarchically under the root
`easyscience` logger. This lets you suppress the whole package or
individual subsystems:

```
easyscience # root — controls everything
├── easyscience.base_classes # EasyList runtime warnings
├── easyscience.legacy # ObjBase / CollectionBase deprecations
├── easyscience.deprecated # @deprecated decorator messages
├── easyscience.fitting # import-availability warnings
│ ├── easyscience.fitting.bumps # Bumps fitting runtime messages
│ ├── easyscience.fitting.lmfit # LMFit fitting runtime messages
│ └── easyscience.fitting.dfo # DFO fitting runtime messages
├── easyscience.variable # parameter warnings
└── easyscience.global_object # undo/redo, debugging diagnostics
```

Setting the level on a parent logger affects all its children, because
child loggers inherit the parent's effective level.

```python
import logging

# Suppress everything from the fitting subsystem (including bumps/lmfit/dfo)
logging.getLogger('easyscience.fitting').setLevel(logging.ERROR)

# Suppress only Bumps runtime messages
logging.getLogger('easyscience.fitting.bumps').setLevel(logging.ERROR)

# Suppress only legacy deprecation notices
logging.getLogger('easyscience.legacy').setLevel(logging.ERROR)
```

## Environment Variable

You can control the log level **before** importing EasyScience by
setting the `EASYSCIENCE_LOG_LEVEL` environment variable. This is useful
when a downstream library cannot configure logging in code before
EasyScience is imported.

**Accepted values:** `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or
a numeric level (e.g. `40`).

**Example — suppressing all import-time messages:**

```powershell
# Windows PowerShell
$env:EASYSCIENCE_LOG_LEVEL = 'ERROR'
python -c "import easyscience" # silent
```

```bash
# Linux / macOS
EASYSCIENCE_LOG_LEVEL=ERROR python -c "import easyscience" # silent
```

Or from Python, set the environment variable before the import:

```python
import os

os.environ['EASYSCIENCE_LOG_LEVEL'] = 'ERROR'
import easyscience # now silent
```

## Temporary Suppression (Context Manager)

When you only want to silence messages during a specific operation, use
the `at_level` context manager:

```python
from easyscience import global_object
import logging

# Messages during the fit are suppressed, then the previous level is restored
with global_object.log.at_level(logging.ERROR):
results = fitter.fit(x, y, weights)
```

This is the recommended pattern for technique-specific libraries that
don't want EasyScience output to leak into their own users' consoles.

## Convenience API

The `global_object.log` object exposes a `level` property plus
convenience methods that mirror `logging` module-level functions:

| Member | Description |
| ------------------ | ------------------------------------------------------------------------ |
| `.level` | Get/set the package-root logger level (`'WARNING'` or `logging.WARNING`) |
| `.debug(msg)` | Log a `DEBUG`-level message |
| `.info(msg)` | Log an `INFO`-level message |
| `.warning(msg)` | Log a `WARNING`-level message |
| `.error(msg)` | Log an `ERROR`-level message |
| `.critical(msg)` | Log a `CRITICAL`-level message |
| `.exception(msg)` | Log an `ERROR`-level message with traceback |
| `.getLogger(name)` | Get a child logger under `easyscience` |
| `.at_level(level)` | Context manager for temporary level change |
| `.suspend()` | Suppress all EasyScience output |
| `.resume()` | Restore the previously set level |

## Recipes

### Recipe 1: Keep your test output clean

When running tests that exercise EasyScience fitting, suppress the
library's messages so they don't clutter your test reports:

```python
import logging
from easyscience import global_object

@pytest.fixture(autouse=True)
def _quiet_easyscience():
# The test body runs *inside* the context manager (yield within `with`)
with global_object.log.at_level(logging.ERROR):
yield
Comment on lines +143 to +147

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.

How does this work? You're only muting the log in the context, so how would this actually mute the log in a test? Wouldn't

global_object.log.set_level('ERROR')

Be better?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

autouse fixture yields inside with at_level(logging.ERROR), so the test body runs within the context and is muted. Your set_level('ERROR') also works and is marginally simpler, but the code is correct

```

### Recipe 2: Library author embedding EasyScience

If you maintain a library that uses EasyScience internally, prevent
EasyScience from writing to your users' consoles:

```python
# In your library's __init__.py, before any EasyScience import:
import os
os.environ.setdefault('EASYSCIENCE_LOG_LEVEL', 'ERROR')
```

### Recipe 3: Debug mode for development

When developing or debugging, see all EasyScience internal diagnostics:

```python
from easyscience import global_object

global_object.log.level = 'DEBUG'

# Your code here — all EasyScience messages will be visible
```

### Recipe 4: See only error messages

Keep the console clean but still see critical problems:

```python
from easyscience import global_object

global_object.log.level = 'ERROR'
```

## Library-Safe Behaviour

EasyScience follows standard library-logging best practices:

- **No `logging.basicConfig()`** — EasyScience never reconfigures the
global logging setup.
- **No default stream handlers** — EasyScience does not attach handlers
that write to `stdout` or `stderr`. It only creates log records.
Applications and test frameworks decide where those records go.
Comment on lines +189 to +191

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.

Isn't this bad? What about users that use easyscience as a standalone fitting package? Do they not see any messages without configuring it? They should . . .

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Python's logging.lastResort handler emits WARNINGs and above to stderr when no handlers are
configured, so standalone easyscience users do see warnings/errors out of the box.
Only INFO/DEBUG are hidden without change of setup.

- **Child loggers inherit** — Child loggers (e.g.
`easyscience.fitting.bumps`) are left at `logging.NOTSET` by default,
so they inherit level and handler configuration from the `easyscience`
package-root logger. Supressing the root suppresses everything.

This means you are in full control. If you don't configure any handlers,
Python's built-in `logging.lastResort` fallback still prints `WARNING`
and above to `stderr`, so standalone users see important messages out of
the box. `INFO` and `DEBUG` messages remain hidden until you opt in. If
you do configure handlers (as `pytest` does via its logging plugin, or
as an application might via `basicConfig`), you control what is shown
and where.
6 changes: 4 additions & 2 deletions docs/docs/user-guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ icon: material/book-open-variant

# :material-book-open-variant: User Guide

This section is currently under development. Please check back later for
updates.
- [:material-volume-high: Controlling Log Output](controlling-log-output.md)
– How to suppress, filter, or redirect the messages that EasyScience
produces. Covers the `EASYSCIENCE_LOG_LEVEL` environment variable, the
logger hierarchy, context managers, and common recipes.
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ nav:
- Installation & Setup: installation-and-setup/index.md
- User Guide:
- User Guide: user-guide/index.md
- Controlling Log Output: user-guide/controlling-log-output.md
- Tutorials:
- Tutorials: tutorials/index.md
- Workshops & Schools:
Expand Down
5 changes: 2 additions & 3 deletions src/easyscience/base_classes/collection_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
Please update your imports.
"""

import warnings
from easyscience import global_object

from ..legacy.collection_base import CollectionBase # noqa: F401

warnings.warn(
global_object.log.warning(
'easyscience.base_classes.collection_base is deprecated. '
'Please import from easyscience.legacy.collection_base instead.',
DeprecationWarning,

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.

So with the new logger, we lose the ability to categorize warnings?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I don't think this is an issue.
Moving from warnings.warn(..., DeprecationWarning) to logging indeed removes the warning filtering by category, -W flags, pytest filterwarnings).
But - categories are replaced by thelogger-name hierarchy (easyscience.legacy, easyscience.deprecated, easyscience.fitting.bumps`, …), which is filterable per-subsystem.

stacklevel=2,
)
8 changes: 4 additions & 4 deletions src/easyscience/base_classes/easy_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from __future__ import annotations

import copy
import warnings
from collections.abc import MutableSequence
from typing import Any
from typing import Callable
Expand All @@ -16,6 +15,7 @@
from typing import TypeVar
from typing import overload

from easyscience import global_object
from easyscience.io.serializer_base import SerializerBase
from easyscience.variable.descriptor_base import DescriptorBase

Expand Down Expand Up @@ -159,7 +159,7 @@ def __setitem__(
if not isinstance(value, tuple(self._protected_types)):
raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}')
if value is not self._data[idx] and value in self:
warnings.warn(
global_object.log.getLogger('base_classes').warning(
f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored'
)
return
Expand All @@ -177,7 +177,7 @@ def __setitem__(
if not isinstance(v, tuple(self._protected_types)):
raise TypeError(f'Items must be one of {self._protected_types}, got {type(v)}')
if v in self and replaced[i] is not v:
warnings.warn(
global_object.log.getLogger('base_classes').warning(
f'Item with unique name "{v.unique_name}" already in EasyList, it will be ignored'
)
new_values[i] = replaced[
Expand Down Expand Up @@ -240,7 +240,7 @@ def insert(self, index: int, value: ProtectedType_) -> None:
elif not isinstance(value, tuple(self._protected_types)):
raise TypeError(f'Items must be one of {self._protected_types}, got {type(value)}')
if value in self:
warnings.warn(
global_object.log.getLogger('base_classes').warning(
f'Item with unique name "{self._get_key(value)}" already in EasyList, it will be ignored'
)
return
Expand Down
5 changes: 2 additions & 3 deletions src/easyscience/base_classes/obj_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
Please update your imports.
"""

import warnings
from easyscience import global_object

from ..legacy.obj_base import ObjBase # noqa: F401

warnings.warn(
global_object.log.warning(
'easyscience.base_classes.obj_base is deprecated. '
'Please import from easyscience.legacy.obj_base instead.',
DeprecationWarning,
stacklevel=2,
)
24 changes: 8 additions & 16 deletions src/easyscience/fitting/available_minimizers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# SPDX-FileCopyrightText: 2024 EasyScience contributors <https://github.com/easyscience>
# SPDX-License-Identifier: BSD-3-Clause

import warnings
from dataclasses import dataclass
from enum import Enum

from easyscience import global_object

# Change to importlib.metadata when Python 3.10 is the minimum version
# import importlib.metadata
# installed_packages = [x.name for x in importlib.metadata.distributions()]
Expand All @@ -15,11 +16,8 @@

lmfit_engine_available = True
except ImportError:
# TODO make this a proper message (use logging?)
warnings.warn(
'LMFit minimization is not available. Probably lmfit has not been installed.',
ImportWarning,

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.

Another category lost

stacklevel=2,
global_object.log.getLogger('fitting').warning(

Check warning on line 19 in src/easyscience/fitting/available_minimizers.py

View check run for this annotation

Codecov / codecov/patch

src/easyscience/fitting/available_minimizers.py#L19

Added line #L19 was not covered by tests
'LMFit minimization is not available. Probably lmfit has not been installed.'
)

bumps_engine_available = False
Expand All @@ -28,11 +26,8 @@

bumps_engine_available = True
except ImportError:
# TODO make this a proper message (use logging?)
warnings.warn(
'Bumps minimization is not available. Probably bumps has not been installed.',
ImportWarning,
stacklevel=2,
global_object.log.getLogger('fitting').warning(

Check warning on line 29 in src/easyscience/fitting/available_minimizers.py

View check run for this annotation

Codecov / codecov/patch

src/easyscience/fitting/available_minimizers.py#L29

Added line #L29 was not covered by tests
'Bumps minimization is not available. Probably bumps has not been installed.'
)

dfo_engine_available = False
Expand All @@ -41,11 +36,8 @@

dfo_engine_available = True
except ImportError:
# TODO make this a proper message (use logging?)
warnings.warn(
'DFO minimization is not available. Probably dfols has not been installed.',
ImportWarning,
stacklevel=2,
global_object.log.getLogger('fitting').warning(

Check warning on line 39 in src/easyscience/fitting/available_minimizers.py

View check run for this annotation

Codecov / codecov/patch

src/easyscience/fitting/available_minimizers.py#L39

Added line #L39 was not covered by tests
'DFO minimization is not available. Probably dfols has not been installed.'
)


Expand Down
10 changes: 8 additions & 2 deletions src/easyscience/fitting/calculators/interface_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from typing import Optional
from typing import Type

from easyscience import global_object

if TYPE_CHECKING:
from abc import ABCMeta

Expand Down Expand Up @@ -96,12 +98,16 @@ def switch(self, new_interface: str, fitter: Optional[Type[Fitter]] = None) -> N
if hasattr(obj, 'update_bindings'):
obj.update_bindings()
except Exception as e:
print(f'Unable to auto generate bindings.\n{e}')
global_object.log.getLogger('fitting.calculators').warning(
'Unable to auto generate bindings.\n%s', e
)
elif hasattr(fitter, 'generate_bindings'):
try:
fitter.generate_bindings()
except Exception as e:
print(f'Unable to auto generate bindings.\n{e}')
global_object.log.getLogger('fitting.calculators').warning(
'Unable to auto generate bindings.\n%s', e
)

@property
def available_interfaces(self) -> List[str]:
Expand Down
Loading
Loading