everbar 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ lint:
15
+ name: Lint (ruff)
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ # setup-uv doesn't publish a floating major tag — must pin to a
21
+ # specific version. Bump as new patches/minors land.
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v8.1.0
24
+ with:
25
+ python-version: "3.13"
26
+ enable-cache: true
27
+
28
+ - name: Sync dev environment
29
+ run: uv sync --all-extras --group dev
30
+
31
+ - name: ruff check
32
+ run: uv run ruff check .
33
+
34
+ - name: ruff format --check
35
+ run: uv run ruff format --check .
36
+
37
+ test:
38
+ name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }})
39
+ runs-on: ${{ matrix.os }}
40
+ strategy:
41
+ fail-fast: false
42
+ matrix:
43
+ os: [ubuntu-latest, macos-latest, windows-latest]
44
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+
48
+ - name: Install uv
49
+ uses: astral-sh/setup-uv@v8.1.0
50
+ with:
51
+ python-version: ${{ matrix.python-version }}
52
+ enable-cache: true
53
+
54
+ - name: Sync environment
55
+ run: uv sync --all-extras --group dev
56
+
57
+ - name: Run tests
58
+ run: uv run pytest -v
@@ -0,0 +1,35 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: write # required to create the GitHub Release
10
+ id-token: write # required for PyPI trusted publishing (OIDC)
11
+
12
+ jobs:
13
+ release:
14
+ runs-on: ubuntu-latest
15
+ environment: pypi # gates the job behind manual approval
16
+ steps:
17
+ - uses: actions/checkout@v5
18
+
19
+ # setup-uv doesn't publish a floating major tag — must pin to a
20
+ # specific version. Bump as new patches/minors land.
21
+ - uses: astral-sh/setup-uv@v8.1.0
22
+ with:
23
+ python-version: "3.13"
24
+
25
+ - name: Build wheel and sdist
26
+ run: uv build
27
+
28
+ - name: Publish to PyPI
29
+ run: uv publish --trusted-publishing automatic
30
+
31
+ - name: Create GitHub Release
32
+ uses: softprops/action-gh-release@v2
33
+ with:
34
+ files: dist/*
35
+ generate_release_notes: true
@@ -0,0 +1,124 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django / Flask stuff (kept for completeness)
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+ instance/
62
+ .webassets-cache
63
+
64
+ # Sphinx documentation
65
+ docs/_build/
66
+
67
+ # PyBuilder
68
+ .pybuilder/
69
+ target/
70
+
71
+ # Jupyter Notebook
72
+ .ipynb_checkpoints
73
+
74
+ # IPython
75
+ profile_default/
76
+ ipython_config.py
77
+
78
+ # pyenv
79
+ .python-version
80
+
81
+ # pipenv / poetry / pdm / uv lockfile preferences left to project
82
+ Pipfile.lock
83
+ poetry.lock
84
+ pdm.lock
85
+ .pdm.toml
86
+ .pdm-python
87
+ .pdm-build/
88
+ uv.lock
89
+
90
+ # PEP 582
91
+ __pypackages__/
92
+
93
+ # Environments
94
+ .env
95
+ .env.*
96
+ !.env.example
97
+ .venv
98
+ venv/
99
+ env/
100
+ ENV/
101
+ env.bak/
102
+ venv.bak/
103
+
104
+ # mypy / pyright / ruff caches
105
+ .mypy_cache/
106
+ .dmypy.json
107
+ dmypy.json
108
+ .pyre/
109
+ .pytype/
110
+ .ruff_cache/
111
+
112
+ # Cython debug symbols
113
+ cython_debug/
114
+
115
+ # IDEs / editors
116
+ .idea/
117
+ .vscode/
118
+ *.swp
119
+ *.swo
120
+ *~
121
+ .DS_Store
122
+
123
+ # Marimo
124
+ __marimo__/
everbar-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: everbar
3
+ Version: 0.1.0
4
+ Summary: A progress bar that works everywhere — terminal, Jupyter, VS Code, Colab, Marimo.
5
+ Project-URL: Homepage, https://github.com/mluttikh/everbar
6
+ Project-URL: Issues, https://github.com/mluttikh/everbar/issues
7
+ License-Expression: MIT
8
+ Keywords: jupyter,marimo,notebook,progress,progressbar,tqdm
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Topic :: Terminals
21
+ Requires-Python: >=3.11
22
+ Provides-Extra: all
23
+ Requires-Dist: ipywidgets>=8.0; extra == 'all'
24
+ Requires-Dist: marimo>=0.10; extra == 'all'
25
+ Requires-Dist: rich>=13.0; extra == 'all'
26
+ Requires-Dist: tqdm>=4.65; extra == 'all'
27
+ Provides-Extra: marimo
28
+ Requires-Dist: marimo>=0.10; extra == 'marimo'
29
+ Provides-Extra: notebook
30
+ Requires-Dist: ipywidgets>=8.0; extra == 'notebook'
31
+ Requires-Dist: tqdm>=4.65; extra == 'notebook'
32
+ Provides-Extra: rich
33
+ Requires-Dist: rich>=13.0; extra == 'rich'
34
+ Provides-Extra: tqdm
35
+ Requires-Dist: tqdm>=4.65; extra == 'tqdm'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # everbar
39
+
40
+ A progress bar that works **everywhere** — terminal, Jupyter, JupyterLab, VS Code notebooks, Google Colab, Marimo, Pyodide, and CI logs. One API, the right backend per environment.
41
+
42
+ > Status: 0.1.0 — alpha. API may shift.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install everbar # core only; uses text fallback if nothing else is installed
48
+ pip install "everbar[tqdm]" # terminal + Jupyter via tqdm
49
+ pip install "everbar[all]" # everything (tqdm, rich, ipywidgets, marimo)
50
+ ```
51
+
52
+ ## Use
53
+
54
+ ```python
55
+ from everbar import Progress
56
+
57
+ for x in Progress(items, desc="Loading"):
58
+ work(x)
59
+
60
+ with Progress(total=100, desc="Steps") as bar:
61
+ for _ in range(100):
62
+ do_step()
63
+ bar.update(1)
64
+ ```
65
+
66
+ ## Overrides
67
+
68
+ ```python
69
+ Progress(items, backend="terminal") # per-call
70
+ ```
71
+
72
+ ```bash
73
+ EVERBAR_BACKEND=terminal python script.py # env var
74
+ ```
75
+
76
+ ```python
77
+ import everbar
78
+ everbar.set_default_backend("terminal") # module-wide
79
+ ```
80
+
81
+ ## How it picks a backend
82
+
83
+ `everbar.detect_environment()` returns one of: `marimo`, `colab`, `kaggle`, `vscode_notebook`, `jupyter`, `jupyter_qt`, `spyder`, `databricks`, `pyodide`, `ipython_terminal`, `terminal`, `non_tty`. Each maps to a backend, with graceful fallback to a log-line text mode when nothing better is available.
@@ -0,0 +1,46 @@
1
+ # everbar
2
+
3
+ A progress bar that works **everywhere** — terminal, Jupyter, JupyterLab, VS Code notebooks, Google Colab, Marimo, Pyodide, and CI logs. One API, the right backend per environment.
4
+
5
+ > Status: 0.1.0 — alpha. API may shift.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install everbar # core only; uses text fallback if nothing else is installed
11
+ pip install "everbar[tqdm]" # terminal + Jupyter via tqdm
12
+ pip install "everbar[all]" # everything (tqdm, rich, ipywidgets, marimo)
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```python
18
+ from everbar import Progress
19
+
20
+ for x in Progress(items, desc="Loading"):
21
+ work(x)
22
+
23
+ with Progress(total=100, desc="Steps") as bar:
24
+ for _ in range(100):
25
+ do_step()
26
+ bar.update(1)
27
+ ```
28
+
29
+ ## Overrides
30
+
31
+ ```python
32
+ Progress(items, backend="terminal") # per-call
33
+ ```
34
+
35
+ ```bash
36
+ EVERBAR_BACKEND=terminal python script.py # env var
37
+ ```
38
+
39
+ ```python
40
+ import everbar
41
+ everbar.set_default_backend("terminal") # module-wide
42
+ ```
43
+
44
+ ## How it picks a backend
45
+
46
+ `everbar.detect_environment()` returns one of: `marimo`, `colab`, `kaggle`, `vscode_notebook`, `jupyter`, `jupyter_qt`, `spyder`, `databricks`, `pyodide`, `ipython_terminal`, `terminal`, `non_tty`. Each maps to a backend, with graceful fallback to a log-line text mode when nothing better is available.
@@ -0,0 +1,125 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "everbar"
7
+ version = "0.1.0"
8
+ description = "A progress bar that works everywhere — terminal, Jupyter, VS Code, Colab, Marimo."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ keywords = ["progress", "progressbar", "tqdm", "jupyter", "marimo", "notebook"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Software Development :: Libraries",
25
+ "Topic :: Terminals",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ tqdm = ["tqdm>=4.65"]
31
+ rich = ["rich>=13.0"]
32
+ notebook = ["ipywidgets>=8.0", "tqdm>=4.65"]
33
+ marimo = ["marimo>=0.10"]
34
+ all = ["tqdm>=4.65", "rich>=13.0", "ipywidgets>=8.0", "marimo>=0.10"]
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=7.0",
39
+ "ruff>=0.6",
40
+ "tqdm>=4.65",
41
+ "rich>=13.0",
42
+ "ipywidgets>=8.0",
43
+ "marimo>=0.10",
44
+ ]
45
+
46
+ [project.urls]
47
+ Homepage = "https://github.com/mluttikh/everbar"
48
+ Issues = "https://github.com/mluttikh/everbar/issues"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/everbar"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+
56
+ [tool.ruff]
57
+ line-length = 80
58
+ target-version = "py311"
59
+ src = ["src", "tests"]
60
+
61
+ [tool.ruff.format]
62
+ quote-style = "double"
63
+ indent-style = "space"
64
+
65
+ [tool.ruff.lint]
66
+ select = [
67
+ "E", # pycodestyle errors
68
+ "W", # pycodestyle warnings
69
+ "F", # pyflakes
70
+ "I", # isort
71
+ "N", # pep8-naming
72
+ "D", # pydocstyle (google style — see [lint.pydocstyle] below)
73
+ "UP", # pyupgrade
74
+ "B", # flake8-bugbear
75
+ "A", # flake8-builtins (don't shadow builtins)
76
+ "ANN", # flake8-annotations (require type hints)
77
+ "ARG", # flake8-unused-arguments
78
+ "C4", # flake8-comprehensions
79
+ "EM", # flake8-errmsg (extract msgs before raise)
80
+ "FBT", # flake8-boolean-trap (bool params should be kw-only)
81
+ "INP", # flake8-no-pep420 (require __init__.py)
82
+ "ISC", # flake8-implicit-str-concat
83
+ "LOG", # flake8-logging
84
+ "PIE", # flake8-pie
85
+ "PT", # flake8-pytest-style
86
+ "RET", # flake8-return
87
+ "SIM", # flake8-simplify
88
+ "SLF", # flake8-self (don't access private from outside)
89
+ "T20", # flake8-print (no stray print())
90
+ "TC", # flake8-type-checking
91
+ "TRY", # tryceratops (exception handling)
92
+ "PERF", # perflint
93
+ "FURB", # refurb (modernization)
94
+ "PL", # pylint subset (E/W/R/C)
95
+ "ERA", # eradicate (commented-out code)
96
+ "RUF", # ruff-specific (includes RUF100: flag unused noqa directives)
97
+ ]
98
+ ignore = [
99
+ "TRY003", # raise long-message — fine for small exceptions here
100
+ "EM101", # raw-string in exception is OK for tiny one-offs
101
+ "EM102", # f-string in exception is OK
102
+ "ISC001", # conflicts with the formatter
103
+ "D203", # one-blank-line-before-class (conflicts with D211)
104
+ "D213", # multi-line-summary-second-line (conflicts with D212)
105
+ "ANN401", # `Any` is permitted (used for **kwargs, __exit__, opaque inner)
106
+ "PLC0415", # imports inside functions — intentional for lazy soft-deps
107
+ "PLR0911", # too-many-return-statements — fine for dispatch funcs
108
+ "PLR0912", # too-many-branches — same
109
+ "PLR0913", # too-many-arguments — Progress legitimately has several
110
+ ]
111
+
112
+ [tool.ruff.lint.pydocstyle]
113
+ convention = "google"
114
+
115
+ [tool.ruff.lint.per-file-ignores]
116
+ "tests/*" = [
117
+ "E501", # long lines OK
118
+ "D", # tests don't need docstrings
119
+ "S101", # asserts are the point
120
+ "SLF001", # accessing _private members in tests is fine
121
+ "PT011", # broad pytest.raises is fine
122
+ "PLR2004", # magic numbers in assertions are fine
123
+ "ARG", # pytest fixtures may pass unused args
124
+ "ANN", # tests don't need full type annotations
125
+ ]
@@ -0,0 +1,7 @@
1
+ """everbar — a progress bar that works everywhere."""
2
+
3
+ from everbar._detect import detect_environment
4
+ from everbar._progress import Progress, set_default_backend
5
+
6
+ __all__ = ["Progress", "detect_environment", "set_default_backend"]
7
+ __version__ = "0.1.0"
@@ -0,0 +1,174 @@
1
+ """Progress backends.
2
+
3
+ Each backend implements the same minimal surface:
4
+
5
+ __enter__ / __exit__ — context-manager use
6
+ __iter__ — iterator-wrapper use
7
+ update(n=1) — manual advance
8
+
9
+ Backends are constructed lazily by ``Progress``. Optional third-party
10
+ dependencies (``tqdm``, ``marimo``) are imported only inside the backend
11
+ that needs them, so ``everbar`` itself has zero required deps.
12
+ """
13
+
14
+ import sys
15
+ import time
16
+ from collections.abc import Iterable, Iterator
17
+ from contextlib import nullcontext
18
+ from typing import Any, Self
19
+
20
+
21
+ def _len_or_none(obj: Any) -> int | None:
22
+ try:
23
+ return len(obj)
24
+ except (TypeError, AttributeError):
25
+ return None
26
+
27
+
28
+ class NullBackend(nullcontext):
29
+ """No-op backend used when ``disable=True``."""
30
+
31
+ def __init__(self, iterable: Iterable[Any] | None = None, **_: Any) -> None:
32
+ super().__init__()
33
+ self._iterable = iterable
34
+
35
+ def __iter__(self) -> Iterator[Any]:
36
+ return iter(self._iterable or ())
37
+
38
+ def update(self, n: int = 1) -> None: # noqa: ARG002 — protocol shape
39
+ return None
40
+
41
+
42
+ class FallbackBackend:
43
+ r"""Log-line backend for non-TTY environments.
44
+
45
+ Emits one line every ``min_interval`` seconds. Suitable for CI logs,
46
+ Kubernetes, CloudWatch — anywhere ``\r`` would just produce spam.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ iterable: Iterable[Any] | None = None,
52
+ total: int | None = None,
53
+ desc: str = "",
54
+ min_interval: float = 2.0,
55
+ stream: Any = None,
56
+ **_: Any,
57
+ ) -> None:
58
+ self._iterable = iterable
59
+ self._total = total if total is not None else _len_or_none(iterable)
60
+ self._desc = desc
61
+ self._min_interval = min_interval
62
+ self._stream = stream if stream is not None else sys.stderr
63
+ self._n = 0
64
+ self._t0 = 0.0
65
+ self._last_log = 0.0
66
+ self._entered = False
67
+
68
+ def __enter__(self) -> Self:
69
+ self._t0 = time.monotonic()
70
+ self._last_log = self._t0
71
+ self._entered = True
72
+ self._log(final=False)
73
+ return self
74
+
75
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
76
+ self._log(final=True)
77
+ self._entered = False
78
+
79
+ def __iter__(self) -> Iterator[Any]:
80
+ with self:
81
+ for item in self._iterable or ():
82
+ yield item
83
+ self.update(1)
84
+
85
+ def update(self, n: int = 1) -> None:
86
+ self._n += n
87
+ now = time.monotonic()
88
+ if now - self._last_log >= self._min_interval:
89
+ self._last_log = now
90
+ self._log(final=False)
91
+
92
+ def _log(self, *, final: bool) -> None:
93
+ elapsed = time.monotonic() - self._t0 if self._t0 else 0.0
94
+ if self._total:
95
+ pct = f"{100 * self._n / self._total:.0f}%"
96
+ total_str = str(self._total)
97
+ else:
98
+ pct = "?"
99
+ total_str = "?"
100
+ marker = "done" if final else "progress"
101
+ desc = f" {self._desc}" if self._desc else ""
102
+ line = (
103
+ f"[{marker}]{desc} {self._n}/{total_str}"
104
+ f" ({pct}) elapsed={elapsed:.1f}s"
105
+ )
106
+ print(line, file=self._stream, flush=True)
107
+
108
+
109
+ class TqdmBackend:
110
+ """Wraps ``tqdm``.
111
+
112
+ Aggregates rather than subclasses so Marimo's function-style monkey-patch
113
+ of ``tqdm_notebook`` (#4016) can't break us.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ iterable: Iterable[Any] | None = None,
119
+ total: int | None = None,
120
+ desc: str = "",
121
+ *,
122
+ notebook: bool = False,
123
+ **kwargs: Any,
124
+ ) -> None:
125
+ if notebook:
126
+ from tqdm.notebook import tqdm as _tqdm
127
+ else:
128
+ from tqdm import tqdm as _tqdm
129
+ self._inner = _tqdm(iterable, total=total, desc=desc, **kwargs)
130
+
131
+ def __enter__(self) -> Self:
132
+ self._inner.__enter__()
133
+ return self
134
+
135
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Any:
136
+ return self._inner.__exit__(exc_type, exc_val, exc_tb)
137
+
138
+ def __iter__(self) -> Iterator[Any]:
139
+ return iter(self._inner)
140
+
141
+ def update(self, n: int = 1) -> None:
142
+ self._inner.update(n)
143
+
144
+
145
+ class MarimoBackend:
146
+ """Marimo-native bar via ``marimo.status.progress_bar``."""
147
+
148
+ def __init__(
149
+ self,
150
+ iterable: Iterable[Any] | None = None,
151
+ total: int | None = None,
152
+ desc: str = "",
153
+ **_: Any,
154
+ ) -> None:
155
+ import marimo as mo
156
+
157
+ self._inner = mo.status.progress_bar(
158
+ iterable,
159
+ total=total if total is not None else _len_or_none(iterable),
160
+ title=desc or None,
161
+ )
162
+
163
+ def __enter__(self) -> Self:
164
+ self._inner.__enter__()
165
+ return self
166
+
167
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Any:
168
+ return self._inner.__exit__(exc_type, exc_val, exc_tb)
169
+
170
+ def __iter__(self) -> Iterator[Any]:
171
+ return iter(self._inner)
172
+
173
+ def update(self, n: int = 1) -> None:
174
+ self._inner.update(n)
@@ -0,0 +1,80 @@
1
+ """Runtime environment detection.
2
+
3
+ Heuristic — the caller can override via the ``backend`` argument on ``Progress``
4
+ or the ``EVERBAR_BACKEND`` environment variable.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from typing import Literal
10
+
11
+ Environment = Literal[
12
+ "marimo",
13
+ "colab",
14
+ "kaggle",
15
+ "vscode_notebook",
16
+ "jupyter",
17
+ "jupyter_qt",
18
+ "spyder",
19
+ "databricks",
20
+ "pyodide",
21
+ "ipython_terminal",
22
+ "terminal",
23
+ "non_tty",
24
+ ]
25
+
26
+
27
+ def detect_environment() -> Environment:
28
+ """Return a string identifying the runtime environment."""
29
+ # 1. Marimo — official API
30
+ try:
31
+ import marimo # type: ignore
32
+
33
+ if marimo.running_in_notebook():
34
+ return "marimo"
35
+ except Exception:
36
+ pass
37
+
38
+ # 2. Pyodide / JupyterLite
39
+ if "pyodide" in sys.modules or sys.platform == "emscripten":
40
+ return "pyodide"
41
+
42
+ # 3. Databricks
43
+ if "DATABRICKS_RUNTIME_VERSION" in os.environ:
44
+ return "databricks"
45
+
46
+ # 4. IPython-based environments
47
+ try:
48
+ from IPython import get_ipython # type: ignore
49
+
50
+ ip = get_ipython()
51
+ except Exception:
52
+ ip = None
53
+
54
+ if ip is not None:
55
+ shell = ip.__class__.__name__
56
+ mods = sys.modules
57
+
58
+ if "google.colab" in mods:
59
+ return "colab"
60
+ if "kaggle_secrets" in mods or "KAGGLE_KERNEL_RUN_TYPE" in os.environ:
61
+ return "kaggle"
62
+ if "VSCODE_PID" in os.environ or (
63
+ hasattr(ip, "user_ns") and "__vsc_ipynb_file__" in ip.user_ns
64
+ ):
65
+ return "vscode_notebook"
66
+ if "spyder_kernels" in mods or "SPY_TESTING" in os.environ:
67
+ return "spyder"
68
+ if shell == "ZMQInteractiveShell":
69
+ return "jupyter"
70
+ if shell == "TerminalInteractiveShell":
71
+ return "ipython_terminal"
72
+
73
+ # 5. Plain Python
74
+ if sys.stdout is not None and hasattr(sys.stdout, "isatty"):
75
+ try:
76
+ if sys.stdout.isatty():
77
+ return "terminal"
78
+ except Exception:
79
+ pass
80
+ return "non_tty"
@@ -0,0 +1,98 @@
1
+ """Public ``Progress`` facade. Picks a backend and delegates."""
2
+
3
+ import os
4
+ from collections.abc import Iterable, Iterator
5
+ from typing import Any, Self
6
+
7
+ from everbar._detect import detect_environment
8
+
9
+ _DEFAULT_BACKEND: str | None = None
10
+
11
+ _NOTEBOOK_ENVS = {"jupyter", "colab", "kaggle", "vscode_notebook", "databricks"}
12
+ _TQDM_STD_ENVS = {"terminal", "ipython_terminal", "spyder", "jupyter_qt"}
13
+
14
+
15
+ def set_default_backend(name: str | None) -> None:
16
+ """Pin the backend globally. Pass ``None`` to restore auto-detection.
17
+
18
+ Valid names: ``"marimo"``, ``"jupyter"``, ``"colab"``, ``"kaggle"``,
19
+ ``"vscode_notebook"``, ``"jupyter_qt"``, ``"spyder"``, ``"databricks"``,
20
+ ``"ipython_terminal"``, ``"terminal"``, ``"pyodide"``, ``"non_tty"``.
21
+ """
22
+ global _DEFAULT_BACKEND # noqa: PLW0603 — module-level pin is the API
23
+ _DEFAULT_BACKEND = name
24
+
25
+
26
+ class Progress:
27
+ """A progress bar that adapts to its environment.
28
+
29
+ Iterator form:
30
+
31
+ for x in Progress(items, desc="Loading"):
32
+ work(x)
33
+
34
+ Context-manager form:
35
+
36
+ with Progress(total=100, desc="Steps") as bar:
37
+ for _ in range(100):
38
+ do_step()
39
+ bar.update(1)
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ iterable: Iterable[Any] | None = None,
45
+ total: int | None = None,
46
+ desc: str = "",
47
+ backend: str | None = None,
48
+ *,
49
+ disable: bool = False,
50
+ **kwargs: Any,
51
+ ) -> None:
52
+ self._iterable = iterable
53
+ self._total = total
54
+ self._desc = desc
55
+ self._kwargs = kwargs
56
+
57
+ chosen = (
58
+ backend or os.environ.get("EVERBAR_BACKEND") or _DEFAULT_BACKEND
59
+ )
60
+ self._env: str = chosen or detect_environment()
61
+ self._impl = self._make_impl(disable=disable)
62
+
63
+ def _make_impl(self, *, disable: bool) -> Any:
64
+ from everbar import _backends
65
+
66
+ if disable:
67
+ return _backends.NullBackend(iterable=self._iterable)
68
+
69
+ common = {"total": self._total, "desc": self._desc, **self._kwargs}
70
+
71
+ try:
72
+ if self._env == "marimo":
73
+ return _backends.MarimoBackend(self._iterable, **common)
74
+ if self._env in _NOTEBOOK_ENVS:
75
+ return _backends.TqdmBackend(
76
+ self._iterable, notebook=True, **common
77
+ )
78
+ if self._env in _TQDM_STD_ENVS:
79
+ return _backends.TqdmBackend(
80
+ self._iterable, notebook=False, **common
81
+ )
82
+ except ImportError:
83
+ pass
84
+
85
+ return _backends.FallbackBackend(self._iterable, **common)
86
+
87
+ def __iter__(self) -> Iterator[Any]:
88
+ return iter(self._impl)
89
+
90
+ def __enter__(self) -> Self:
91
+ self._impl.__enter__()
92
+ return self
93
+
94
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Any:
95
+ return self._impl.__exit__(exc_type, exc_val, exc_tb)
96
+
97
+ def update(self, n: int = 1) -> None:
98
+ self._impl.update(n)
File without changes
@@ -0,0 +1,27 @@
1
+ """Smoke tests for detect_environment()."""
2
+
3
+ from everbar import detect_environment
4
+
5
+ VALID = {
6
+ "marimo",
7
+ "colab",
8
+ "kaggle",
9
+ "vscode_notebook",
10
+ "jupyter",
11
+ "jupyter_qt",
12
+ "spyder",
13
+ "databricks",
14
+ "pyodide",
15
+ "ipython_terminal",
16
+ "terminal",
17
+ "non_tty",
18
+ }
19
+
20
+
21
+ def test_returns_known_environment():
22
+ env = detect_environment()
23
+ assert env in VALID
24
+
25
+
26
+ def test_idempotent():
27
+ assert detect_environment() == detect_environment()
@@ -0,0 +1,61 @@
1
+ """End-to-end tests for the Progress facade.
2
+
3
+ We force ``backend="non_tty"`` so tests don't depend on tqdm/marimo and don't
4
+ spam the terminal during CI.
5
+ """
6
+
7
+ import io
8
+
9
+ from everbar import Progress, set_default_backend
10
+ from everbar._backends import FallbackBackend, NullBackend
11
+
12
+
13
+ def test_iterator_form_yields_all_items():
14
+ items = [1, 2, 3, 4, 5]
15
+ out = list(Progress(items, backend="non_tty"))
16
+ assert out == items
17
+
18
+
19
+ def test_context_manager_updates():
20
+ with Progress(total=10, backend="non_tty") as bar:
21
+ for _ in range(10):
22
+ bar.update(1)
23
+ assert bar._impl._n == 10 # type: ignore[attr-defined]
24
+
25
+
26
+ def test_disable_uses_null_backend():
27
+ p = Progress([1, 2, 3], disable=True)
28
+ assert isinstance(p._impl, NullBackend)
29
+ assert list(p) == [1, 2, 3]
30
+
31
+
32
+ def test_explicit_backend_overrides_detection():
33
+ p = Progress([1, 2, 3], backend="non_tty")
34
+ assert isinstance(p._impl, FallbackBackend)
35
+
36
+
37
+ def test_env_var_overrides(monkeypatch):
38
+ monkeypatch.setenv("EVERBAR_BACKEND", "non_tty")
39
+ p = Progress([1, 2, 3])
40
+ assert isinstance(p._impl, FallbackBackend)
41
+
42
+
43
+ def test_set_default_backend(monkeypatch):
44
+ monkeypatch.delenv("EVERBAR_BACKEND", raising=False)
45
+ set_default_backend("non_tty")
46
+ try:
47
+ p = Progress([1, 2, 3])
48
+ assert isinstance(p._impl, FallbackBackend)
49
+ finally:
50
+ set_default_backend(None)
51
+
52
+
53
+ def test_fallback_writes_lines(monkeypatch):
54
+ buf = io.StringIO()
55
+ bar = FallbackBackend(total=3, desc="x", min_interval=0.0, stream=buf)
56
+ with bar:
57
+ for _ in range(3):
58
+ bar.update(1)
59
+ output = buf.getvalue()
60
+ assert "[progress]" in output or "[done]" in output
61
+ assert "x" in output