Skip to content

Commit

Permalink
feat(python): Enable creation of independently reusable Config inst…
Browse files Browse the repository at this point in the history
…ances
  • Loading branch information
alexander-beedie committed Nov 28, 2024
1 parent 0b218d8 commit 3dfb310
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 34 deletions.
34 changes: 30 additions & 4 deletions py-polars/docs/source/reference/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,37 @@ explicitly calling one or more of the available "set\_" methods on it...
Use as a decorator
------------------

In the same vein, you can also use ``Config`` as a function decorator to
temporarily set options for the duration of the function call:
In the same vein, you can also use a ``Config`` instance as a function decorator
to temporarily set options for the duration of the function call:

.. code-block:: python
@pl.Config(set_ascii_tables=True)
def write_ascii_frame_to_stdout(df: pl.DataFrame) -> None:
cfg_ascii_frames = pl.Config(ascii_tables=True, apply_on_context_enter=True)
@cfg_ascii_frames
def write_markdown_frame_to_stdout(df: pl.DataFrame) -> None:
sys.stdout.write(str(df))
Multiple Config instances
-------------------------
You may want to establish related bundles of `Config` options for use in different
parts of your code. Usually options are set immediately on `Config` init, meaning
the `Config` instance cannot be reused; however, you can defer this so that options
are only invoked when entering context scope (which includes function entry if used
as a decorator)._

This allows you to create multiple *reusable* `Config` instances in one place, update
and modify them centrally, and apply them as needed throughout your codebase.

.. code-block:: python
cfg_verbose = pl.Config(verbose=True, apply_on_context_enter=True)
cfg_markdown = pl.Config(tbl_formatting="MARKDOWN", apply_on_context_enter=True)
@cfg_markdown
def write_markdown_frame_to_stdout(df: pl.DataFrame) -> None:
sys.stdout.write(str(df))
@cfg_verbose
def do_various_things():
...
24 changes: 12 additions & 12 deletions py-polars/polars/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ def register_expr_namespace(name: str) -> Callable[[type[NS]], type[NS]]:
See Also
--------
register_dataframe_namespace: Register functionality on a DataFrame.
register_lazyframe_namespace: Register functionality on a LazyFrame.
register_series_namespace: Register functionality on a Series.
register_dataframe_namespace : Register functionality on a DataFrame.
register_lazyframe_namespace : Register functionality on a LazyFrame.
register_series_namespace : Register functionality on a Series.
Examples
--------
Expand Down Expand Up @@ -129,9 +129,9 @@ def register_dataframe_namespace(name: str) -> Callable[[type[NS]], type[NS]]:
See Also
--------
register_expr_namespace: Register functionality on an Expr.
register_lazyframe_namespace: Register functionality on a LazyFrame.
register_series_namespace: Register functionality on a Series.
register_expr_namespace : Register functionality on an Expr.
register_lazyframe_namespace : Register functionality on a LazyFrame.
register_series_namespace : Register functionality on a Series.
Examples
--------
Expand Down Expand Up @@ -227,9 +227,9 @@ def register_lazyframe_namespace(name: str) -> Callable[[type[NS]], type[NS]]:
See Also
--------
register_expr_namespace: Register functionality on an Expr.
register_dataframe_namespace: Register functionality on a DataFrame.
register_series_namespace: Register functionality on a Series.
register_expr_namespace : Register functionality on an Expr.
register_dataframe_namespace : Register functionality on a DataFrame.
register_series_namespace : Register functionality on a Series.
Examples
--------
Expand Down Expand Up @@ -328,9 +328,9 @@ def register_series_namespace(name: str) -> Callable[[type[NS]], type[NS]]:
See Also
--------
register_expr_namespace: Register functionality on an Expr.
register_dataframe_namespace: Register functionality on a DataFrame.
register_lazyframe_namespace: Register functionality on a LazyFrame.
register_expr_namespace : Register functionality on an Expr.
register_dataframe_namespace : Register functionality on a DataFrame.
register_lazyframe_namespace : Register functionality on a LazyFrame.
Examples
--------
Expand Down
96 changes: 79 additions & 17 deletions py-polars/polars/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,15 @@ class Config(contextlib.ContextDecorator):
... pass
"""

_context_options: ConfigParameters | None = None
_original_state: str = ""

def __init__(
self, *, restore_defaults: bool = False, **options: Unpack[ConfigParameters]
self,
*,
restore_defaults: bool = False,
apply_on_context_enter: bool = False,
**options: Unpack[ConfigParameters],
) -> None:
"""
Initialise a Config object instance for context manager usage.
Expand All @@ -187,12 +192,19 @@ def __init__(
restore_defaults
set all options to their default values (this is applied before
setting any other options).
apply_on_context_enter
defer applying the options until a context is entered. This allows you
to create multiple `Config` instances with different options, and then
reuse them independently as context managers or function decorators
with specific bundles of parameters.
**options
keyword args that will set the option; equivalent to calling the
named "set_<option>" method with the given value.
Examples
--------
Customise Polars table formatting while in context scope:
>>> df = pl.DataFrame({"abc": [1.0, 2.5, 5.0], "xyz": [True, False, True]})
>>> with pl.Config(
... # these options will be set for scope duration
Expand All @@ -208,24 +220,51 @@ def __init__(
| 1.0 | true |
| 2.5 | false |
| 5.0 | true |
Establish several independent Config instances for use in different contexts;
setting `apply_on_context_enter=True` defers setting the parameters until a
context (or function, when used as a decorator) is actually entered:
>>> cfg_polars_verbose = pl.Config(
... verbose=True,
... apply_on_context_enter=True,
... )
>>> cfg_polars_detailed_tables = pl.Config(
... tbl_rows=25,
... tbl_cols=25,
... tbl_width_chars=200,
... apply_on_context_enter=True,
... )
These Config instances can now be applied independently and re-used:
>>> @cfg_polars_verbose
... def traced_function(df: pl.DataFrame) -> pl.DataFrame:
... return polars_operations(df)
>>> @cfg_polars_detailed_tables
... def print_detailed_frames(*frames: pl.DataFrame) -> None:
... for df in frames:
... print(df)
"""
# save original state _before_ any changes are made
self._original_state = self.save()

if restore_defaults:
self.restore_defaults()

for opt, value in options.items():
if not hasattr(self, opt) and not opt.startswith("set_"):
opt = f"set_{opt}"
if not hasattr(self, opt):
msg = f"`Config` has no option {opt!r}"
raise AttributeError(msg)
getattr(self, opt)(value)
if apply_on_context_enter:
# defer setting options; apply only on entering a new context
self._context_options = options
else:
# apply the given options immediately
self._set_config_params(**options)
self._context_options = None

def __enter__(self) -> Self:
"""Support setting temporary Config options that are reset on scope exit."""
"""Support setting Config options that are reset on scope exit."""
self._original_state = self._original_state or self.save()
if self._context_options:
self._set_config_params(**self._context_options)
return self

def __exit__(
Expand All @@ -238,6 +277,25 @@ def __exit__(
self.restore_defaults().load(self._original_state)
self._original_state = ""

def __eq__(self, other: object) -> bool:
if not isinstance(other, Config):
return False
return (self._original_state == other._original_state) and (
self._context_options == other._context_options
)

def __ne__(self, other: object) -> bool:
return not self.__eq__(other)

def _set_config_params(self, **options: Unpack[ConfigParameters]) -> None:
for opt, value in options.items():
if not hasattr(self, opt) and not opt.startswith("set_"):
opt = f"set_{opt}"
if not hasattr(self, opt):
msg = f"`Config` has no option {opt!r}"
raise AttributeError(msg)
getattr(self, opt)(value)

@classmethod
def load(cls, cfg: str) -> Config:
"""
Expand All @@ -251,7 +309,7 @@ def load(cls, cfg: str) -> Config:
See Also
--------
load_from_file : Load (and set) Config options from a JSON file.
save: Save the current set of Config options as a JSON string or file.
save : Save the current set of Config options as a JSON string or file.
"""
try:
options = json.loads(cfg)
Expand Down Expand Up @@ -285,7 +343,7 @@ def load_from_file(cls, file: Path | str) -> Config:
See Also
--------
load : Load (and set) Config options from a JSON string.
save: Save the current set of Config options as a JSON string or file.
save : Save the current set of Config options as a JSON string or file.
"""
try:
options = Path(normalize_filepath(file)).read_text()
Expand Down Expand Up @@ -389,7 +447,7 @@ def state(
cls, *, if_set: bool = False, env_only: bool = False
) -> dict[str, str | None]:
"""
Show the current state of all Config variables as a dict.
Show the current state of all Config variables in the environment as a dict.
Parameters
----------
Expand Down Expand Up @@ -422,7 +480,11 @@ def set_ascii_tables(cls, active: bool | None = True) -> type[Config]:
"""
Use ASCII characters to display table outlines.
Set False to revert to the default UTF8_FULL_CONDENSED formatting style.
Set False to revert to the standard UTF8_FULL_CONDENSED formatting style.
See Also
--------
set_tbl_formatting : Set the table formatting style (includes Markdown option).
Examples
--------
Expand Down Expand Up @@ -969,7 +1031,7 @@ def set_tbl_column_data_type_inline(
cls, active: bool | None = True
) -> type[Config]:
"""
Moves the data type inline with the column name (to the right, in parentheses).
Display the data type next to the column name (to the right, in parentheses).
Examples
--------
Expand Down Expand Up @@ -1151,11 +1213,11 @@ def set_tbl_hide_column_names(cls, active: bool | None = True) -> type[Config]:
@classmethod
def set_tbl_hide_dtype_separator(cls, active: bool | None = True) -> type[Config]:
"""
Hide the '---' separator between the column names and column types.
Hide the '---' separator displayed between the column names and column types.
See Also
--------
set_tbl_column_data_type_inline
set_tbl_column_data_type_inline : Display the data type inline with the colname.
Examples
--------
Expand Down
2 changes: 1 addition & 1 deletion py-polars/polars/dataframe/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -1525,7 +1525,7 @@ def item(self, row: int | None = None, column: int | str | None = None) -> Any:
See Also
--------
row: Get the values of a single row, either by index or by predicate.
row : Get the values of a single row, either by index or by predicate.
Notes
-----
Expand Down
88 changes: 88 additions & 0 deletions py-polars/tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,94 @@ def test_config_load_save_context() -> None:
assert os.environ["POLARS_VERBOSE"]


def test_config_instances() -> None:
# establish two config instances that defer setting their options
cfg_markdown = pl.Config(
tbl_formatting="MARKDOWN",
apply_on_context_enter=True,
)
cfg_compact = pl.Config(
tbl_rows=4,
tbl_cols=4,
tbl_column_data_type_inline=True,
apply_on_context_enter=True,
)

# check instance (in)equality
assert cfg_markdown != cfg_compact
assert cfg_markdown == pl.Config(
tbl_formatting="MARKDOWN", apply_on_context_enter=True
)

# confirm that the options have not been applied yet
assert os.environ.get("POLARS_FMT_TABLE_FORMATTING") is None

# confirm that the deferred options are applied when the instance context
# is entered into, and that they can be re-used without leaking state
@cfg_markdown
def fn1() -> str | None:
return os.environ.get("POLARS_FMT_TABLE_FORMATTING")

assert fn1() == "MARKDOWN"
assert os.environ.get("POLARS_FMT_TABLE_FORMATTING") is None

with cfg_markdown: # can re-use instance as decorator and context
assert os.environ.get("POLARS_FMT_TABLE_FORMATTING") == "MARKDOWN"
assert os.environ.get("POLARS_FMT_TABLE_FORMATTING") is None

@cfg_markdown
def fn2() -> str | None:
return os.environ.get("POLARS_FMT_TABLE_FORMATTING")

assert fn2() == "MARKDOWN"
assert os.environ.get("POLARS_FMT_TABLE_FORMATTING") is None

df = pl.DataFrame({f"c{idx}": [idx] * 10 for idx in range(10)})

@cfg_compact
def fn3(df: pl.DataFrame) -> str:
return repr(df)

# reuse config instance and confirm state does not leak between invocations
for _ in range(3):
assert (
fn3(df)
== dedent("""
shape: (10, 10)
┌──────────┬──────────┬───┬──────────┬──────────┐
│ c0 (i64) ┆ c1 (i64) ┆ … ┆ c8 (i64) ┆ c9 (i64) │
╞══════════╪══════════╪═══╪══════════╪══════════╡
│ 0 ┆ 1 ┆ … ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ … ┆ 8 ┆ 9 │
│ … ┆ … ┆ … ┆ … ┆ … │
│ 0 ┆ 1 ┆ … ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ … ┆ 8 ┆ 9 │
└──────────┴──────────┴───┴──────────┴──────────┘""").lstrip()
)

assert (
repr(df)
== dedent("""
shape: (10, 10)
┌─────┬─────┬─────┬─────┬───┬─────┬─────┬─────┬─────┐
│ c0 ┆ c1 ┆ c2 ┆ c3 ┆ … ┆ c6 ┆ c7 ┆ c8 ┆ c9 │
│ --- ┆ --- ┆ --- ┆ --- ┆ ┆ --- ┆ --- ┆ --- ┆ --- │
│ i64 ┆ i64 ┆ i64 ┆ i64 ┆ ┆ i64 ┆ i64 ┆ i64 ┆ i64 │
╞═════╪═════╪═════╪═════╪═══╪═════╪═════╪═════╪═════╡
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
│ 0 ┆ 1 ┆ 2 ┆ 3 ┆ … ┆ 6 ┆ 7 ┆ 8 ┆ 9 │
└─────┴─────┴─────┴─────┴───┴─────┴─────┴─────┴─────┘""").lstrip()
)


def test_config_scope() -> None:
pl.Config.set_verbose(False)
pl.Config.set_tbl_cols(8)
Expand Down

0 comments on commit 3dfb310

Please sign in to comment.