Skip to content

Commit

Permalink
There and back again
Browse files Browse the repository at this point in the history
  • Loading branch information
twm committed Jul 27, 2024
1 parent 11ad413 commit d659ea0
Show file tree
Hide file tree
Showing 3 changed files with 39 additions and 105 deletions.
59 changes: 19 additions & 40 deletions src/incremental/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ def _findPath(path, package): # type: (str, str) -> str
return current_dir
else:
raise ValueError(
"Can't find the directory of package {}: I looked in {} and {}".format(
"Can't find the directory of project {}: I looked in {} and {}".format(
package, src_dir, current_dir
)
)
Expand Down Expand Up @@ -404,9 +404,13 @@ def _get_setuptools_version(dist): # type: (_Distribution) -> None
but this hook is always called before setuptools loads anything
from ``pyproject.toml``.
"""
# When operating in a packaging context (i.e. building an sdist or wheel)
# pyproject.toml will always be found in the current working directory.
config = _load_pyproject_toml("./pyproject.toml", opt_in=False)
try:
# When operating in a packaging context (i.e. building an sdist or
# wheel) pyproject.toml will always be found in the current working
# directory.
config = _load_pyproject_toml("./pyproject.toml")
except Exception:
return
if not config or not config.opt_in:
return

Expand Down Expand Up @@ -466,13 +470,13 @@ class _IncrementalConfig:
"""

package: str
"""The package name, capitalized as in the package metadata."""
"""The project name, capitalized as in the project metadata."""

path: str
"""Path to the package root"""


def _load_pyproject_toml(toml_path, opt_in): # type: (str, bool) -> Optional[_IncrementalConfig]
def _load_pyproject_toml(toml_path): # type: (str) -> Optional[_IncrementalConfig]
"""
Load Incremental configuration from a ``pyproject.toml``
Expand All @@ -483,31 +487,11 @@ def _load_pyproject_toml(toml_path, opt_in): # type: (str, bool) -> Optional[_I
@param toml_path:
Path to the ``pyproject.toml`` to load.
@param opt_in:
Are we operating in a context where Incremental has been
affirmatively requested?
Otherwise we do our best to *never* raise an exception until we
find a ``[tool.incremental]`` opt-in. This is important when
operating within a setuptools entry point because those hooks
are invoked anytime the L{Distribution} class is initialized,
which happens in non-packaging contexts that don't match the
"""
try:
with open(toml_path, "rb") as f:
data = _load_toml(f)
except Exception:
if opt_in:
raise
return None

tool_incremental = _extract_tool_incremental(data, opt_in)
with open(toml_path, "rb") as f:
data = _load_toml(f)

# Do we have an affirmative opt-in to use Incremental?
opt_in = opt_in or tool_incremental is not None
if not opt_in:
return None
tool_incremental = _extract_tool_incremental(data)

# Extract the project name
package = None
Expand All @@ -522,7 +506,7 @@ def _load_pyproject_toml(toml_path, opt_in): # type: (str, bool) -> Optional[_I
if package is None:
# We can't proceed without a project name.
raise ValueError("""\
Incremental failed to extract the package name from pyproject.toml. Specify it like:
Incremental failed to extract the project name from pyproject.toml. Specify it like:
[project]
name = "Foo"
Expand All @@ -535,33 +519,28 @@ def _load_pyproject_toml(toml_path, opt_in): # type: (str, bool) -> Optional[_I
""")
if not isinstance(package, str):
raise TypeError(
"Package name must be a string, but found {}".format(type(package))
"The project name must be a string, but found {}".format(type(package))
)

return _IncrementalConfig(
opt_in=opt_in,
opt_in=tool_incremental is not None,
package=package,
path=_findPath(os.path.dirname(toml_path), package),
)


def _extract_tool_incremental(data, opt_in): # type: (Dict[str, object], bool) -> Optional[Dict[str, object]]
def _extract_tool_incremental(data): # type: (Dict[str, object]) -> Optional[Dict[str, object]]
if "tool" not in data:
return None
if not isinstance(data["tool"], dict):
if opt_in:
raise ValueError("[tool] must be a table")
return None
raise ValueError("[tool] must be a table")
if "incremental" not in data["tool"]:
return None

tool_incremental = data["tool"]["incremental"]
if not isinstance(tool_incremental, dict):
if opt_in:
raise ValueError("[tool.incremental] must be a table")
return None
raise ValueError("[tool.incremental] must be a table")

# At this point we've found a [tool.incremental] table, so we have opt_in
if not {"name"}.issuperset(tool_incremental.keys()):
raise ValueError("Unexpected key(s) in [tool.incremental]")
return tool_incremental
Expand Down
2 changes: 1 addition & 1 deletion src/incremental/_hatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class IncrementalVersionSource(VersionSourceInterface):
def get_version_data(self) -> _VersionData: # type: ignore[override]
path = os.path.join(self.root, "./pyproject.toml")
# If the Hatch plugin is running at all we've already opted in.
config = _load_pyproject_toml(path, opt_in=True)
config = _load_pyproject_toml(path)
assert config is not None, "Failed to read {}".format(path)
return {"version": _existing_version(config.path).public()}

Expand Down
83 changes: 19 additions & 64 deletions src/incremental/tests/test_pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class VerifyPyprojectDotTomlTests(TestCase):
"""Test the `_load_pyproject_toml` helper function"""

def _loadToml(
self, toml: str, opt_in: bool, *, path: Union[Path, str, None] = None
self, toml: str, *, path: Union[Path, str, None] = None
) -> Optional[_IncrementalConfig]:
"""
Read a TOML snipped from a temporary file with `_load_pyproject_toml`
Expand All @@ -34,7 +34,7 @@ def _loadToml(
f.write(toml)

try:
return _load_pyproject_toml(path_, opt_in)
return _load_pyproject_toml(path_)
except Exception as e:
if hasattr(e, "add_note"):
e.add_note( # type: ignore[attr-defined]
Expand All @@ -48,50 +48,27 @@ def test_fileNotFound(self):
there is opt-in.
"""
path = os.path.join(cast(str, self.mktemp()), "pyproject.toml")
self.assertIsNone(_load_pyproject_toml(path, False))
self.assertRaises(FileNotFoundError, _load_pyproject_toml, path, True)
self.assertRaises(FileNotFoundError, _load_pyproject_toml, path)

def test_brokenToml(self):
"""
Syntactially invalid TOML is ignored unless there's an opt-in.
"""
toml = '[project]\nname = "abc' # truncated
self.assertRaises(Exception, self._loadToml, toml)

self.assertIsNone(self._loadToml(toml, False))
self.assertRaises(Exception, self._loadToml, toml, True)

def test_configMissing(self):
def test_nameMissing(self):
"""
A ``pyproject.toml`` that exists but provides no relevant configuration
is ignored unless opted in.
`ValueError` is raised when we can't extract the project name.
"""
for toml in [
"\n",
"[tool.notincremental]\n",
"[project]\n",
]:
self.assertIsNone(self._loadToml(toml, False))

def test_nameMissing(self):
"""
`ValueError` is raised when ``[tool.incremental]`` is present but
the project name isn't available. The ``[tool.incremental]``
section counts as opt-in.
"""
for toml in [
"[tool.incremental]\n",
"[project]\n[tool.incremental]\n",
]:
self.assertRaises(ValueError, self._loadToml, toml, False)
self.assertRaises(ValueError, self._loadToml, toml, True)

def test_nameInvalidNoOptIn(self):
"""
An invalid project name is ignored when there's no opt-in.
"""
self.assertIsNone(
self._loadToml("[project]\nname = false\n", False),
)
self.assertRaises(ValueError, self._loadToml, toml)

def test_nameInvalidOptIn(self):
"""
Expand All @@ -103,7 +80,8 @@ def test_nameInvalidOptIn(self):
"[tool.incremental]\nname = -1\n",
"[tool.incremental]\n[project]\nname = 1.0\n",
]:
self.assertRaises(TypeError, self._loadToml, toml, True)
with self.assertRaisesRegex(TypeError, "The project name must be a string"):
self._loadToml(toml)

def test_toolIncrementalInvalid(self):
"""
Expand All @@ -117,8 +95,7 @@ def test_toolIncrementalInvalid(self):
"[tool]\nincremental = 123\n",
"[tool]\nincremental = null\n",
]:
self.assertIsNone(self._loadToml(toml, False))
self.assertRaises(ValueError, self._loadToml, toml, True)
self.assertRaises(ValueError, self._loadToml, toml)

def test_toolIncrementalUnexpecteKeys(self):
"""
Expand All @@ -129,7 +106,7 @@ def test_toolIncrementalUnexpecteKeys(self):
"[tool.incremental]\nfoo = false\n",
'[tool.incremental]\nname = "OK"\nother = false\n',
]:
self.assertRaises(ValueError, self._loadToml, toml, False)
self.assertRaises(ValueError, self._loadToml, toml)

def test_setuptoolsOptIn(self):
"""
Expand All @@ -145,7 +122,7 @@ def test_setuptoolsOptIn(self):
'[project]\nname = "Foo"\n[tool.incremental]\n',
'[tool.incremental]\nname = "Foo"\n',
]:
config = self._loadToml(toml, False, path=root / "pyproject.toml")
config = self._loadToml(toml, path=root / "pyproject.toml")

self.assertEqual(
config,
Expand All @@ -156,59 +133,37 @@ def test_setuptoolsOptIn(self):
),
)

def test_noToolIncrementalSection(self):
def test_packagePathRequired(self):
"""
We don't produce config unless we find opt-in.
The ``[project]`` section doesn't imply opt-in, even if we can
recover the project name from it.
"""
root = Path(self.mktemp())
pkg = root / "foo" # A valid package directory.
pkg.mkdir(parents=True)

config = self._loadToml(
'[project]\nname = "foo"\n',
opt_in=False,
path=root / "pyproject.toml",
)

self.assertIsNone(config)

def test_pathNotFoundOptIn(self):
"""
Once opted in, raise `ValueError` when the package root can't
be resolved.
Raise `ValueError` when the package root can't be resolved.
"""
root = Path(self.mktemp())
root.mkdir() # Contains no package directory.

with self.assertRaisesRegex(ValueError, "Can't find the directory of package"):
with self.assertRaisesRegex(ValueError, "Can't find the directory of project "):
self._loadToml(
'[project]\nname = "foo"\n',
opt_in=True,
path=root / "pyproject.toml",
)

def test_noToolIncrementalSectionOptIn(self):
def test_noToolIncrementalSection(self):
"""
If opted in (i.e. in the Hatch plugin) then the [tool.incremental]
table is completely optional.
The ``[tool.incremental]`` table is not strictly required, but its
``opt_in=False`` indicates its absence.
"""
root = Path(self.mktemp())
pkg = root / "src" / "foo"
pkg.mkdir(parents=True)

config = self._loadToml(
'[project]\nname = "Foo"\n',
opt_in=True,
path=root / "pyproject.toml",
)

self.assertEqual(
config,
_IncrementalConfig(
opt_in=True,
opt_in=False,
package="Foo",
path=str(pkg),
),
Expand Down

0 comments on commit d659ea0

Please sign in to comment.