Skip to content

Commit 735f88e

Browse files
98 implement usethis tool import linter (#528)
* Add module finding function * Renaming import-linter contracts for simplicity * Add `usethis tool import-linter` implementation * Add import-linter to ALL_TOOL_COMMANDS * Add ImportLinterTool to ALL_TOOLS * Use `force=True` argument for merged config sections * Don't check for tool use if no associated ruff rules * Update syncer test * Handle case of failed graph build * Include .importlinter in files_manager() * Add import-linter to _HOOK_ORDER * Update `usethis list` tests * Implement `get_project_name` function * Refactor uses of `get_name` to use `get_project_name` Cope with FileNotFoundError * update test to use new fallback project name * Broaden exception handling for pyproject.toml project section being missing * Update test to reflect new fallback case * Upate test to reflect new fallback case * Add tests * Add test for removal case * Add tests, new fallback method for get_project_name * Protect INI section and option removal with exchandler Update test * Add _vary_network_conn decorator to tests * Add xfail test for case of pre-commit with uv * Add unit tests for ImportLinterTool.print_how_to_use * Fix monkeypatching of sys.path in tests Add test that small contracts are dropped * Add test for cyclic imports * Add test for Bitbucket integration * Remove package-name-based fallback for get_project_name * Add tests for print_how_to_use for PyprojectFmtTool and RequirementsTxtTool * Update test to reflect inclusion of empty architectures * Remove unnecessary error handling * Add regex-based config keys for INI Import Linter configuration * Properly configure INI Import Linter contracts * Test adding to an existing INI file * Fix pre-commit usage name * Force the source dir onto Python path for grimp delegation, add tests, tweak contract name to only include final key * Fix capfd in test_pre_commit_and_uv * Fix 'TestPrintHowToUse' for Import Linter * Fix test regex * Fix match condition in test_config for Import Linter * Revert change to capfd in test_uv_only * Don't add INI contracts if they already exist * Add failing tests * Don't check INI keys if file doesn't exist * Reflect new section newline behaviour in INI files for import-linter tests * Handle root_packages vs. root_package * Only display warning message once for no-packages-found in Import Linter config * Calculate min depth across all root packages * Remove unused function * Test a more nested case * Use full import name as contract name
1 parent 4fa99e9 commit 735f88e

File tree

25 files changed

+1297
-103
lines changed

25 files changed

+1297
-103
lines changed

pyproject.toml

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ default-groups = [ "test", "dev", "doc" ]
157157
root_packages = [ "usethis" ]
158158

159159
[[tool.importlinter.contracts]]
160-
name = "Modular Design"
160+
name = "usethis"
161161
type = "layers"
162162
layers = [
163163
"_test | __main__",
@@ -175,7 +175,7 @@ exhaustive = true
175175
exhaustive_ignores = [ "_version" ]
176176

177177
[[tool.importlinter.contracts]]
178-
name = "Interface Independence"
178+
name = "usethis._interface"
179179
type = "layers"
180180
layers = [
181181
# Note; if you're adding an interface, make sure it's in the README too.
@@ -185,7 +185,7 @@ containers = [ "usethis._interface" ]
185185
exhaustive = true
186186

187187
[[tool.importlinter.contracts]]
188-
name = "Core Modular Design"
188+
name = "usethis._core"
189189
type = "layers"
190190
layers = [
191191
# docstyle uses (Ruff) tool, badge uses readme
@@ -196,28 +196,37 @@ containers = [ "usethis._core" ]
196196
exhaustive = true
197197

198198
[[tool.importlinter.contracts]]
199-
name = "Integrations Modular Design"
199+
name = "usethis._integrations"
200200
type = "layers"
201201
layers = [
202202
"ci | pre_commit",
203203
"uv | pytest | pydantic | sonarqube",
204-
"project | file | python",
204+
"project | python",
205+
"file",
205206
]
206207
containers = [ "usethis._integrations" ]
207208
exhaustive = true
208209

209210
[[tool.importlinter.contracts]]
210-
name = "File Integrations Modular Design"
211+
name = "usethis._integrations.file"
211212
type = "layers"
212213
layers = [
213214
"pyproject_toml | setup_cfg",
214215
"ini | toml | yaml",
216+
"dir",
215217
]
216218
containers = [ "usethis._integrations.file" ]
217219
exhaustive = true
218220

219221
[[tool.importlinter.contracts]]
220-
name = "CI Integrations Modular Design"
222+
name = "usethis._pipeweld"
223+
type = "layers"
224+
layers = [ "func", "result", "containers | ops" ]
225+
containers = [ "usethis._pipeweld" ]
226+
exhaustive = true
227+
228+
[[tool.importlinter.contracts]]
229+
name = "usethis._integrations.ci"
221230
type = "layers"
222231
layers = [
223232
"bitbucket | github",

src/usethis/_config_file.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def files_manager() -> Iterator[None]:
2222
CoverageRCManager(),
2323
DotRuffTOMLManager(),
2424
DotPytestINIManager(),
25+
DotImportLinterManager(),
2526
PytestINIManager(),
2627
RuffTOMLManager(),
2728
ToxINIManager(),
@@ -45,12 +46,12 @@ def relative_path(self) -> Path:
4546
return Path(".coveragerc")
4647

4748

48-
class DotRuffTOMLManager(TOMLFileManager):
49-
"""Class to manage the .ruff.toml file."""
49+
class DotImportLinterManager(INIFileManager):
50+
"""Class to manage the .importlinter file."""
5051

5152
@property
5253
def relative_path(self) -> Path:
53-
return Path(".ruff.toml")
54+
return Path(".importlinter")
5455

5556

5657
class DotPytestINIManager(INIFileManager):
@@ -61,6 +62,14 @@ def relative_path(self) -> Path:
6162
return Path(".pytest.ini")
6263

6364

65+
class DotRuffTOMLManager(TOMLFileManager):
66+
"""Class to manage the .ruff.toml file."""
67+
68+
@property
69+
def relative_path(self) -> Path:
70+
return Path(".ruff.toml")
71+
72+
6473
class PytestINIManager(INIFileManager):
6574
"""Class to manage the pytest.ini file."""
6675

src/usethis/_core/badge.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99

1010
from usethis._console import tick_print, warn_print
1111
from usethis._core.readme import add_readme, get_readme_path
12-
from usethis._integrations.file.pyproject_toml.errors import PyprojectTOMLError
13-
from usethis._integrations.file.pyproject_toml.name import get_name
12+
from usethis._integrations.project.name import get_project_name
1413

1514
if TYPE_CHECKING:
1615
from typing_extensions import Self
@@ -40,15 +39,7 @@ def get_pre_commit_badge() -> Badge:
4039

4140

4241
def get_pypi_badge() -> Badge:
43-
try:
44-
name = get_name()
45-
except PyprojectTOMLError:
46-
# Note; we don't want to create pyproject.toml because if it doesn't exist,
47-
# the package is unlikely to be on PyPI. They could be using setup.py etc.
48-
# So a second-best heuristic is the name of the current directory.
49-
# Note that we need to filter out invalid characters
50-
# https://packaging.python.org/en/latest/specifications/name-normalization/#name-format
51-
name = re.sub(r"[^a-zA-Z0-9._-]", "", Path.cwd().stem)
42+
name = get_project_name()
5243
return Badge(
5344
markdown=f"[![PyPI Version](https://img.shields.io/pypi/v/{name}.svg)](<https://pypi.python.org/pypi/{name})"
5445
)

src/usethis/_core/readme.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
from usethis._console import box_print, tick_print
66
from usethis._integrations.file.pyproject_toml.errors import PyprojectTOMLError
7-
from usethis._integrations.file.pyproject_toml.name import get_description, get_name
7+
from usethis._integrations.file.pyproject_toml.name import get_description
8+
from usethis._integrations.project.name import get_project_name
89
from usethis._integrations.uv.init import ensure_pyproject_toml
910

1011

@@ -21,10 +22,7 @@ def add_readme() -> None:
2122

2223
ensure_pyproject_toml()
2324

24-
try:
25-
project_name = get_name()
26-
except PyprojectTOMLError:
27-
project_name = None
25+
project_name = get_project_name()
2826

2927
try:
3028
project_description = get_description()

src/usethis/_core/show.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
from __future__ import annotations
22

33
from usethis._config import usethis_config
4-
from usethis._integrations.file.pyproject_toml.name import get_name
4+
from usethis._integrations.project.name import get_project_name
55
from usethis._integrations.sonarqube.config import get_sonar_project_properties
66
from usethis._integrations.uv.init import ensure_pyproject_toml
77

88

99
def show_name() -> None:
10-
with usethis_config.set(quiet=True):
11-
ensure_pyproject_toml()
12-
print(get_name())
10+
print(get_project_name())
1311

1412

1513
def show_sonarqube_config() -> None:

src/usethis/_core/tool.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
CodespellTool,
2525
CoverageTool,
2626
DeptryTool,
27+
ImportLinterTool,
2728
PreCommitTool,
2829
PyprojectFmtTool,
2930
PyprojectTOMLTool,
@@ -94,6 +95,28 @@ def use_deptry(*, remove: bool = False) -> None:
9495
tool.remove_managed_files()
9596

9697

98+
def use_import_linter(*, remove: bool = False) -> None:
99+
tool = ImportLinterTool()
100+
101+
ensure_pyproject_toml()
102+
103+
if not remove:
104+
tool.add_dev_deps()
105+
tool.add_configs()
106+
if PreCommitTool().is_used():
107+
tool.add_pre_commit_repo_configs()
108+
else:
109+
tool.update_bitbucket_steps()
110+
111+
tool.print_how_to_use()
112+
else:
113+
tool.remove_pre_commit_repo_configs()
114+
tool.remove_bitbucket_steps()
115+
tool.remove_configs()
116+
tool.remove_dev_deps()
117+
tool.remove_managed_files()
118+
119+
97120
def use_pre_commit(*, remove: bool = False) -> None:
98121
tool = PreCommitTool()
99122
pyproject_fmt_tool = PyprojectFmtTool()
@@ -331,8 +354,9 @@ def use_ruff(*, remove: bool = False, minimal: bool = False) -> None:
331354
"UP",
332355
]
333356
for _tool in ALL_TOOLS:
334-
if _tool.is_used():
335-
rules += _tool.get_associated_ruff_rules()
357+
associated_rules = _tool.get_associated_ruff_rules()
358+
if associated_rules and _tool.is_used():
359+
rules += associated_rules
336360
ignored_rules = [
337361
"PLR2004", # https://github.com/nathanjmcdougall/usethis-python/issues/105
338362
"SIM108", # https://github.com/nathanjmcdougall/usethis-python/issues/118

src/usethis/_integrations/file/dir.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from pathlib import Path
2+
3+
4+
def get_project_name_from_dir() -> str:
5+
# Use the name of the parent directory
6+
# Names must start and end with a letter or digit and may only contain -, _, ., and
7+
# alphanumeric characters. Any other characters will be dropped. If there are no
8+
# valid characters, the name will be "hello_world".
9+
# https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#name
10+
# https://packaging.python.org/en/latest/specifications/name-normalization/#name-format
11+
dir_name = Path.cwd().name
12+
name = "".join(c for c in dir_name if c.isalnum() or c in {"-", "_", "."})
13+
if not name:
14+
name = "hello_world"
15+
16+
return name

src/usethis/_integrations/file/ini/io_.py

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,17 @@ def __contains__(self, keys: Sequence[Key]) -> bool:
9999
return False
100100

101101
if len(keys) == 0:
102-
# The root level exists if the file exists
103102
return True
104103
elif len(keys) == 1:
105104
(section_key,) = keys
106-
return section_key in root
105+
for _ in _itermatches(root.sections(), key=section_key):
106+
return True
107107
elif len(keys) == 2:
108-
section_key, option_key = keys
109-
try:
110-
return option_key in root[section_key]
111-
except KeyError:
112-
return False
113-
else:
114-
# Nested keys can't exist in INI files.
115-
return False
108+
(section_key, option_key) = keys
109+
for section_strkey in _itermatches(root.sections(), key=section_key):
110+
for _ in _itermatches(root[section_strkey].options(), key=option_key):
111+
return True
112+
return False
116113

117114
def __getitem__(self, item: Sequence[Key]) -> Any:
118115
keys = item
@@ -194,7 +191,7 @@ def _set_value_in_root(
194191
# We don't want to remove existing ones to keep their positions.
195192
for section_key in root.sections():
196193
if section_key not in root_dict:
197-
root.remove_section(name=section_key)
194+
_remove_section(updater=root, section_key=section_key)
198195

199196
TypeAdapter(dict).validate_python(root_dict)
200197
assert isinstance(root_dict, dict)
@@ -208,7 +205,9 @@ def _set_value_in_root(
208205
# We need to remove options that are not in the new dict
209206
# We don't want to remove existing ones to keep their positions.
210207
if option_key not in section_dict:
211-
root.remove_option(section=section_key, option=option_key)
208+
_remove_option(
209+
updater=root, section_key=section_key, option_key=option_key
210+
)
212211
else:
213212
root.add_section(section_key)
214213

@@ -249,7 +248,9 @@ def _set_value_in_section(
249248
# We need to remove options that are not in the new dict
250249
# We don't want to remove existing ones to keep their positions.
251250
if option_key not in section_dict:
252-
root.remove_option(section=section_key, option=option_key)
251+
_remove_option(
252+
updater=root, section_key=section_key, option_key=option_key
253+
)
253254

254255
for option_key, option in section_dict.items():
255256
INIFileManager._validated_set(
@@ -392,17 +393,19 @@ def _delete_strkeys(self, strkeys: Sequence[str]) -> None:
392393
if len(strkeys) == 0:
393394
removed = False
394395
for section_key in root.sections():
395-
removed |= root.remove_section(name=section_key)
396+
removed |= _remove_section(updater=root, section_key=section_key)
396397
elif len(strkeys) == 1:
397398
(section_key,) = strkeys
398-
removed = root.remove_section(name=section_key)
399+
removed = _remove_section(updater=root, section_key=section_key)
399400
elif len(strkeys) == 2:
400401
section_key, option_key = strkeys
401-
removed = root.remove_option(section=section_key, option=option_key)
402+
removed = _remove_option(
403+
updater=root, section_key=section_key, option_key=option_key
404+
)
402405

403406
# Cleanup section if empty
404407
if not root[section_key].options():
405-
removed = root.remove_section(name=section_key)
408+
_remove_section(updater=root, section_key=section_key)
406409
else:
407410
msg = (
408411
f"INI files do not support nested config, whereas access to "
@@ -481,11 +484,11 @@ def _remove_from_list_in_option(
481484

482485
if len(new_values) == 0:
483486
# Remove the option if empty
484-
root.remove_option(section=section_key, option=option_key)
487+
_remove_option(updater=root, section_key=section_key, option_key=option_key)
485488

486489
# Remove the section if empty
487490
if not root[section_key].options():
488-
root.remove_section(name=section_key)
491+
_remove_section(updater=root, section_key=section_key)
489492

490493
elif len(new_values) == 1:
491494
# If only one value left, set it directly
@@ -544,6 +547,17 @@ def _(value: Section) -> dict[str, Any]:
544547
return {option.key: option.value for option in value.iter_options()}
545548

546549

550+
def _remove_option(updater: INIDocument, section_key: str, option_key: str) -> bool:
551+
try:
552+
return updater.remove_option(section=section_key, option=option_key)
553+
except configparser.NoSectionError as err:
554+
raise INIValueMissingError(err) from None
555+
556+
557+
def _remove_section(updater: INIDocument, section_key: str) -> bool:
558+
return updater.remove_section(name=section_key)
559+
560+
547561
def _itermatches(values: Iterable[str], /, *, key: Key):
548562
"""Iterate through an iterable and find all matches for a key."""
549563
for value in values:

src/usethis/_integrations/file/pyproject_toml/errors.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,16 @@ class UnexpectedPyprojectTOMLIOError(PyprojectTOMLError, UnexpectedTOMLIOError):
3434
"""Raised when an unexpected attempt is made to read or write the pyproject.toml file."""
3535

3636

37-
class PyprojectTOMLProjectNameError(PyprojectTOMLError):
38-
"""Raised when the 'project.name' key is missing or invalid in 'pyproject.toml'."""
37+
class PyprojectTOMLProjectSectionError(PyprojectTOMLError):
38+
"""Raised when the 'project' section is missing or invalid in 'pyproject.toml'."""
3939

4040

41-
class PyprojectTOMLProjectDescriptionError(PyprojectTOMLError):
42-
"""Raised when the 'project.description' key is missing or invalid in 'pyproject.toml'."""
41+
class PyprojectTOMLProjectNameError(PyprojectTOMLProjectSectionError):
42+
"""Raised when the 'project.name' key is missing or invalid in 'pyproject.toml'."""
4343

4444

45-
class PyprojectTOMLProjectSectionError(PyprojectTOMLError):
46-
"""Raised when the 'project' section is missing or invalid in 'pyproject.toml'."""
45+
class PyprojectTOMLProjectDescriptionError(PyprojectTOMLProjectSectionError):
46+
"""Raised when the 'project.description' key is missing or invalid in 'pyproject.toml'."""
4747

4848

4949
class PyprojectTOMLValueAlreadySetError(PyprojectTOMLError, TOMLValueAlreadySetError):

0 commit comments

Comments
 (0)