Skip to content

Commit

Permalink
feat: allow marimo configuration in notebook.py as script metadata (p…
Browse files Browse the repository at this point in the history
…er PEP 723) (#3794)
  • Loading branch information
mscolnick authored Feb 14, 2025
1 parent d8e081b commit 73732d2
Show file tree
Hide file tree
Showing 13 changed files with 415 additions and 59 deletions.
23 changes: 23 additions & 0 deletions docs/guides/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ default_width = "full"

You can override any user configuration setting in this way. To find these settings run `marimo config show`.

!!! note "Overridden settings"
Settings overridden in `pyproject.toml` or script metadata cannot be changed through the marimo editor's settings menu. Any changes made to overridden settings in the editor will not take effect.

### Script Metadata Configuration

You can also configure marimo settings directly in your notebook files using script metadata (PEP 723). Add a `script` block at the top of your notebook:

```python
# /// script
# [tool.marimo.runtime]
# auto_instantiate = false
# on_cell_change = "lazy"
# [tool.marimo.display]
# theme = "dark"
# cell_output = "below"
# ///
```

!!! note "Configuration precedence"
Script metadata configuration has the highest precedence, followed by `pyproject.toml` configuration, then user configuration:

**Script config > pyproject.toml config > user config**

## Environment Variables

marimo supports the following environment variables for advanced configuration:
Expand Down
11 changes: 11 additions & 0 deletions docs/guides/configuration/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ You can further customize your notebook by adding custom HTML in the `<head>` se

See the [Custom HTML Head](html_head.md) guide for more details.

## Forcing dark mode

In order to force a theme for an application, you can override the marimo configuration specifically for an application using the script metadata. See the [Script Configuration](../configuration/index.md#script-metadata-configuration) for more details.

```python
# /// script
# [tool.marimo.display]
# theme = "dark"
# ///
```

## Targeting cells

You can target a cell's styles from the `data-cell-name` attribute. You can also target a cell's output with the `data-cell-role="output"` attribute.
Expand Down
43 changes: 43 additions & 0 deletions examples/misc/custom_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "marimo",
# ]
# [tool.marimo.runtime]
# auto_instantiate = false
# on_cell_change = "lazy"
# [tool.marimo.display]
# theme = "dark"
# cell_output = "below"
# ///
import marimo

__generated_with = "0.11.4"
app = marimo.App(width="medium")


@app.cell
def _():
import marimo as mo

return (mo,)


@app.cell
def _(mo):
mo.md(
r"""
This is not auto-run because it has custom marimo configuration in the file header:
```toml
[tool.marimo.runtime]
auto_instantiate = false
on_cell_change = "lazy"
```
"""
)
return


if __name__ == "__main__":
app.run()
35 changes: 3 additions & 32 deletions marimo/_cli/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,16 @@
from marimo._cli.print import bold, echo, green, muted
from marimo._config.settings import GLOBAL_SETTINGS
from marimo._dependencies.dependencies import DependencyManager
from marimo._utils.scripts import read_pyproject_from_script
from marimo._utils.versions import is_editable

LOGGER = _loggers.marimo_logger()

REGEX = (
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
)


def _get_dependencies(script: str) -> List[str] | None:
"""Get dependencies from string representation of script."""
try:
pyproject = _read_pyproject(script) or {}
pyproject = read_pyproject_from_script(script) or {}
return _pyproject_toml_to_requirements_txt(pyproject)
except Exception as e:
LOGGER.warning(f"Failed to parse dependencies: {e}")
Expand Down Expand Up @@ -121,32 +118,6 @@ def get_dependencies_from_filename(name: str) -> List[str]:
return []


def _read_pyproject(script: str) -> Dict[str, Any] | None:
"""
Read the pyproject.toml file from the script.
Adapted from https://peps.python.org/pep-0723/#reference-implementation
"""
name = "script"
matches = list(
filter(lambda m: m.group("type") == name, re.finditer(REGEX, script))
)
if len(matches) > 1:
raise ValueError(f"Multiple {name} blocks found")
elif len(matches) == 1:
content = "".join(
line[2:] if line.startswith("# ") else line[1:]
for line in matches[0].group("content").splitlines(keepends=True)
)
import tomlkit

pyproject = tomlkit.parse(content)

return pyproject
else:
return None


def _get_python_version_requirement(
pyproject: Dict[str, Any] | None,
) -> str | None:
Expand Down Expand Up @@ -287,7 +258,7 @@ def construct_uv_command(args: list[str], name: str | None) -> list[str]:
# Get Python version requirement if available
if name is not None and os.path.exists(name):
contents, _ = FileContentReader().read_file(name)
pyproject = _read_pyproject(contents)
pyproject = read_pyproject_from_script(contents)
python_version = (
_get_python_version_requirement(pyproject)
if pyproject is not None
Expand Down
11 changes: 10 additions & 1 deletion marimo/_config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@
else:
from typing import NotRequired

from typing import Any, Dict, List, Literal, Optional, TypedDict, Union, cast
from typing import (
Any,
Dict,
List,
Literal,
Optional,
TypedDict,
Union,
cast,
)

from marimo._output.rich_help import mddoc
from marimo._utils.deep_merge import deep_merge
Expand Down
74 changes: 66 additions & 8 deletions marimo/_config/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import os
from abc import abstractmethod
from pathlib import Path
from typing import Optional, Union, cast

from marimo import _loggers
Expand All @@ -13,8 +14,16 @@
merge_config,
merge_default_config,
)
from marimo._config.reader import read_marimo_config, read_pyproject_config
from marimo._config.secrets import mask_secrets, remove_secret_placeholders
from marimo._config.reader import (
get_marimo_config_from_pyproject_dict,
read_marimo_config,
read_pyproject_marimo_config,
)
from marimo._config.secrets import (
mask_secrets,
mask_secrets_partial,
remove_secret_placeholders,
)
from marimo._config.utils import (
get_or_create_user_config_path,
)
Expand All @@ -25,12 +34,23 @@
def get_default_config_manager(
*, current_path: Optional[str]
) -> MarimoConfigManager:
"""
Get the default config manager
Args:
current_path: The current path of the notebook, or a directory.
If the current path is a notebook, the config manager will read the
project configuration from the notebook following PEP 723.
"""
# Current path should be the notebook file
# If it's not known, use the current working directory
if current_path is None:
current_path = os.getcwd()

return MarimoConfigManager(
UserConfigManager(), ProjectConfigManager(current_path)
UserConfigManager(),
ProjectConfigManager(current_path),
ScriptConfigManager(current_path),
)


Expand Down Expand Up @@ -110,18 +130,58 @@ def __init__(self, start_path: str) -> None:

def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig:
try:
project_config = read_pyproject_config(self.start_path)
project_config = read_pyproject_marimo_config(self.start_path)
if project_config is None:
return {}
except Exception as e:
LOGGER.warning("Failed to read project config: %s", e)
return {}

if hide_secrets:
return cast(PartialMarimoConfig, mask_secrets(project_config))
return mask_secrets_partial(project_config)
return project_config


class ScriptConfigManager(PartialMarimoConfigReader):
"""Read the script configuration following PEP 723
This looks like a pyproject.toml serialized as a comment in the header
of the script.
"""

def __init__(self, filename: Optional[str]) -> None:
self.filename = filename

def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig:
if self.filename is None:
return {}
try:
filepath = Path(self.filename)
if not filepath.is_file():
return {}

from marimo._utils.scripts import read_pyproject_from_script

script_content = filepath.read_text()
script_config = read_pyproject_from_script(script_content)
if script_config is None:
return {}

marimo_config = get_marimo_config_from_pyproject_dict(
script_config
)
if marimo_config is None:
return {}

except Exception as e:
LOGGER.warning("Failed to read script config: %s", e)
return {}

if hide_secrets:
return mask_secrets_partial(marimo_config)
return marimo_config


class UserConfigManager(MarimoConfigReader):
"""Read and write the user configuration"""

Expand Down Expand Up @@ -194,7 +254,5 @@ def __init__(self, override_config: PartialMarimoConfig) -> None:

def get_config(self, *, hide_secrets: bool = True) -> PartialMarimoConfig:
if hide_secrets:
return cast(
PartialMarimoConfig, mask_secrets(self.override_config)
)
return mask_secrets_partial(self.override_config)
return self.override_config
24 changes: 19 additions & 5 deletions marimo/_config/reader.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright 2024 Marimo. All rights reserved.
from __future__ import annotations

from pathlib import Path
from typing import Optional, Union, cast
from typing import Any, Optional, Union, cast

from marimo import _loggers
from marimo._config.config import PartialMarimoConfig
Expand All @@ -14,15 +16,28 @@ def read_marimo_config(path: str) -> PartialMarimoConfig:
return cast(PartialMarimoConfig, read_toml(path))


def read_pyproject_config(
def read_pyproject_marimo_config(
start_path: Union[str, Path],
) -> Optional[PartialMarimoConfig]:
"""Read the pyproject.toml configuration."""
"""Find the nearest pyproject.toml file, then read the marimo tool config."""
path = find_nearest_pyproject_toml(start_path)
if path is None:
return None
pyproject_config = read_toml(path)
marimo_tool_config = pyproject_config.get("tool", {}).get("marimo", None)
marimo_tool_config = get_marimo_config_from_pyproject_dict(
pyproject_config
)
if marimo_tool_config is None:
return None
LOGGER.debug("Found marimo config in pyproject.toml at %s", path)
return marimo_tool_config


def get_marimo_config_from_pyproject_dict(
pyproject_dict: dict[str, Any],
) -> Optional[PartialMarimoConfig]:
"""Get the marimo config from a pyproject.toml dictionary."""
marimo_tool_config = pyproject_dict.get("tool", {}).get("marimo", None)
if marimo_tool_config is None:
return None
if not isinstance(marimo_tool_config, dict):
Expand All @@ -31,7 +46,6 @@ def read_pyproject_config(
marimo_tool_config,
)
return None
LOGGER.debug("Found marimo config in pyproject.toml at %s", path)
return cast(PartialMarimoConfig, marimo_tool_config)


Expand Down
14 changes: 10 additions & 4 deletions marimo/_config/secrets.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# Copyright 2024 Marimo. All rights reserved.
from typing import Any, Dict, TypeVar, Union, cast
from __future__ import annotations

from typing import Any, Dict, TypeVar, cast

from marimo._config.config import MarimoConfig, PartialMarimoConfig
from marimo._config.utils import deep_copy

SECRET_PLACEHOLDER = "********"

# TODO: mypy doesn't like using @overload here


def mask_secrets_partial(config: PartialMarimoConfig) -> PartialMarimoConfig:
return cast(PartialMarimoConfig, mask_secrets(cast(MarimoConfig, config)))


def mask_secrets(
config: Union[MarimoConfig, PartialMarimoConfig],
) -> MarimoConfig:
def mask_secrets(config: MarimoConfig) -> MarimoConfig:
def deep_remove_from_path(path: list[str], obj: Dict[str, Any]) -> None:
key = path[0]
if key not in obj:
Expand Down
Loading

0 comments on commit 73732d2

Please sign in to comment.