Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ repos:
hooks:
- id: check-api-reference
name: check-api-reference
pass_filenames: false
entry: python -m utils.check_api_reference
language: python
additional_dependencies: [polars]
Expand Down
8 changes: 4 additions & 4 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@

To verify the installation, start the Python REPL and execute:

```python
>>> import narwhals
>>> narwhals.__version__
'1.38.2'
```python exec="1" source="above" session="quickstart" result="python"
import narwhals

print(narwhals.__version__)
```

If you see the version number, then the installation was successful!
Expand Down
17 changes: 16 additions & 1 deletion narwhals/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import typing as _t

from narwhals import dependencies
from narwhals import dtypes
from narwhals import exceptions
Expand Down Expand Up @@ -81,7 +83,7 @@
from narwhals.utils import maybe_reset_index
from narwhals.utils import maybe_set_index

__version__ = "1.38.2"
__version__: str

__all__ = [
"Array",
Expand Down Expand Up @@ -165,3 +167,16 @@
"to_py_scalar",
"when",
]


def __getattr__(name: _t.Literal["__version__"]) -> str: # type: ignore[misc]
Copy link
Member

Choose a reason for hiding this comment

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

To be completely honest I'd rather just update in two places, I have no idea what this is doing

Happy to take the installation update in the docs page though πŸ‘

Copy link
Member Author

Choose a reason for hiding this comment

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

Just to make sure I understand correctly - the concern with importing importlib.metadata is regarding startup time? see comment

Copy link
Member

Choose a reason for hiding this comment

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

i feel uneasy about the global - do you have a reference from another project that does this?

Copy link
Member Author

@FBruzzesi FBruzzesi May 10, 2025

Choose a reason for hiding this comment

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

Looking for __version__ = version(__name__) on github you will get many results

A couple of projects I know that made it in the first few pages of the list are:

Edit: some other project do the analogous of __version__ = version("narwhals") (but couldn't find something I know)

Copy link
Member

Choose a reason for hiding this comment

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

sure, thanks

still not sure we need it though - there's some other nice improvements in here though, happy to take those

regarding perf, from narwhals import __version__ currently takes 0m0.156s, and from importlib import metadata takes 0m0.108s

Copy link
Member

@dangotbanned dangotbanned May 10, 2025

Choose a reason for hiding this comment

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

Some notes:

IMO, I don't think there's a use-case for checking narwhals.__version__, but if people want to do that - the cost of it isn't paid by everyone else since we're providing it lazily.
Are there any search results of a package checking it? (sorry I'm replying from my phone πŸ˜…)

@MarcoGorelli I understand your caution and ultimately will defer πŸ˜‰ to you, but I think this balances:

  • Theoretical backwards-compatibility
  • Avoids introducing performance overhead for those that don't need it
  • Ensures that potential cost is only paid once
  • Lowers our maintenance burden, by simplifying version bumps

Copy link
Member

@MarcoGorelli MarcoGorelli May 10, 2025

Choose a reason for hiding this comment

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

thanks - could you elaborate on the global part please? i've had a look at the linked projects and they don't seem to have it

if we don't deem __version__ to be useful to most users, presumably we don't need the global either?

Copy link
Member

@dangotbanned dangotbanned May 10, 2025

Choose a reason for hiding this comment

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

thanks - could you elaborate on the global part please? i've had a look at the linked projects and they don't seem to have it

Sure @MarcoGorelli!

I'm assuming you're referring to the projects in (#2514 (comment))?
The 3 stdlib examples I gave in (#2514 (comment)) all featured use of global:

I guess for some more info on global, these might be helpful

As a more concrete example, we can do the following on main:

import narwhals as nw

>>> nw.__version__
'1.38.2'
>>> "__version__" in nw.__dict__
True
>>> nw.__dict__["__version__"]
'1.38.2'
>>> nw.__version__ == nw.__dict__["__version__"] == nw.__dict__.__getitem__("__version__")
True

But in this PR, we can see where the __getattr__ hook slips in:

import narwhals as nw

>>> nw.__dict__["__version__"]
KeyError: '__version__'
>>> nw.__version__
'1.38.2'
>>> nw.__dict__["__version__"]
'1.38.2'

Without the global:

  • __version__ is never added to nw.__dict__
  • Each lookup of nw.__version__ always goes via __getattr__, which is triggered after the regular __dict__ lookup fails

So the global is what provides the lazy import, since the work is only ever done once and only when a user asks for it πŸ™‚.
All subsequent lookups behave identically to a regular lookup into the namespace.


As another example, polars has 2 behaviors related to __getattr__:

Copy link
Member

Choose a reason for hiding this comment

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

if name == "__version__":
global __version__ # noqa: PLW0603

from importlib import metadata

__version__ = metadata.version(__name__)
return __version__
else:
msg = f"module {__name__!r} has no attribute {name!r}"
raise AttributeError(msg)
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ exclude = [
line-length = 90
fix = true
target-version = "py38"
extend-exclude = ["utils/bump_version.py", "**/this.py"]
extend-exclude = ["**/this.py"]

[tool.ruff.lint]
preview = true
Expand Down Expand Up @@ -189,6 +189,7 @@ builtins-ignorelist = ["format"]
]
"tpch/tests/*" = ["S101"]
"utils/*" = ["S311", "PTH123"]
"utils/bump_version.py" = ["S603", "S607", "T201"]
"tpch/execute/*" = ["T201"]
"tpch/notebooks/*" = [
"ANN001",
Expand Down
14 changes: 14 additions & 0 deletions tests/version_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

import re
from pathlib import Path

import narwhals as nw


def test_version_matches_pyproject() -> None:
with Path("pyproject.toml").open(encoding="utf-8") as file:
content = file.read()
pyproject_version = re.search(r'version = "(.*)"', content).group(1) # type: ignore[union-attr]

assert nw.__version__ == pyproject_version
91 changes: 36 additions & 55 deletions utils/bump_version.py
Original file line number Diff line number Diff line change
@@ -1,80 +1,61 @@
# mypy: ignore
# ruff: noqa
import re
import subprocess
from __future__ import annotations

import subprocess as sp
import sys

out = subprocess.run(["git", "fetch", "upstream", "--tags"])
GIT = "git"
UV = "uv"
FETCH = "fetch"
PUSH = "push"
COMMIT = "commit"
UPSTREAM = "upstream"
TAGS = "--tags"
TAG = "tag"
VERSION = "version"

out = sp.run([GIT, FETCH, UPSTREAM, TAGS], check=False)
if out.returncode != 0:
print(
"Something went wrong with the release process, please check the Narwhals Wiki and try again."
"Something went wrong with the release process, please check the Narwhals Wiki for "
"at https://github.com/narwhals-dev/narwhals/wiki#release-process and try again."
)
print(out)
sys.exit(1)
subprocess.run(["git", "reset", "--hard", "upstream/main"])
sp.run([GIT, "reset", "--hard", "upstream/main"], check=False)

if (
subprocess.run(
["git", "branch", "--show-current"], text=True, capture_output=True
current_branch := sp.run(
[GIT, "branch", "--show-current"], text=True, capture_output=True, check=False
).stdout.strip()
!= "bump-version"
):
msg = "`bump_version.py` should be run from `bump-version` branch"
) != "bump-version":
msg = f"`bump_version.py` should be run from `bump-version` branch instead of `{current_branch}`"
raise RuntimeError(msg)

# Delete local tags, if present
try:
# Get the list of all tags
result = subprocess.run(
["git", "tag", "-l"], capture_output=True, text=True, check=True
)
result = sp.run([GIT, TAG, "-l"], capture_output=True, text=True, check=True)
tags = result.stdout.splitlines() # Split the tags into a list by lines

# Delete each tag using git tag -d
for tag in tags:
subprocess.run(["git", "tag", "-d", tag], check=True)
if tags:
# Delete each tag using git tag -d
sp.run([GIT, TAG, "-d", *tags], check=True)

print("All local tags have been deleted.")
except subprocess.CalledProcessError as e:
except sp.CalledProcessError as e:
print(f"An error occurred: {e}")

subprocess.run(["git", "fetch", "upstream", "--tags"])
subprocess.run(["git", "fetch", "upstream", "--prune", "--tags"])
sp.run([GIT, FETCH, UPSTREAM, TAGS], check=False)
sp.run([GIT, FETCH, UPSTREAM, "--prune", TAGS], check=False)

how = sys.argv[1]

with open("pyproject.toml", encoding="utf-8") as f:
content = f.read()
old_version = re.search(r'version = "(.*)"', content).group(1) # pyright: ignore[reportOptionalMemberAccess]
version = old_version.split(".")
if how == "patch":
version = ".".join(version[:-1] + [str(int(version[-1]) + 1)])
elif how == "minor":
version = ".".join(version[:-2] + [str(int(version[-2]) + 1), "0"])
elif how == "major":
version = ".".join([str(int(version[0]) + 1), "0", "0"])
content = content.replace(f'version = "{old_version}"', f'version = "{version}"')
with open("pyproject.toml", "w", encoding="utf-8") as f:
f.write(content)

with open("narwhals/__init__.py", encoding="utf-8") as f:
content = f.read()
content = content.replace(
f'__version__ = "{old_version}"',
f'__version__ = "{version}"',
)
with open("narwhals/__init__.py", "w", encoding="utf-8") as f:
f.write(content)

with open("docs/installation.md", encoding="utf-8") as f:
content = f.read()
content = content.replace(
f"'{old_version}'",
f"'{version}'",
)
with open("docs/installation.md", "w", encoding="utf-8") as f:
f.write(content)
new_version = sp.run(
[UV, VERSION, "--bump", how, "--short"], capture_output=True, text=True, check=False
).stdout

subprocess.run(["git", "commit", "-a", "-m", f"release: Bump version to {version}"])
subprocess.run(["git", "tag", "-a", f"v{version}", "-m", f"v{version}"])
subprocess.run(["git", "push", "upstream", "HEAD", "--follow-tags"])
subprocess.run(["git", "push", "upstream", "HEAD:stable", "-f", "--follow-tags"])
sp.run([GIT, COMMIT, "-a", "-m", f"release: Bump version to {new_version}"], check=False)
sp.run([GIT, TAG, "-a", f"v{new_version}", "-m", f"v{new_version}"], check=False)
sp.run([GIT, PUSH, UPSTREAM, "HEAD", "--follow-tags"], check=False)
sp.run([GIT, PUSH, UPSTREAM, "HEAD:stable", "-f", "--follow-tags"], check=False)
2 changes: 1 addition & 1 deletion utils/check_api_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def iter_api_reference_names(tp: type[Any]) -> Iterator[str]:

# Top level functions
top_level_functions = [
i for i in dir(nw) if not i[0].isupper() and i[0] != "_" and i not in files
i for i in nw.__all__ if not i[0].isupper() and i[0] != "_" and i not in files
]
with open("docs/api-reference/narwhals.md") as fd:
content = fd.read()
Expand Down
Loading