diff --git a/AGENTS.md b/AGENTS.md index 4fbb1eb83..9fefc1c63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ usethis # usethis: Automatically manage Python tooling │ │ ├── errors # Error types for project integration operations. │ │ ├── imports # Import graph analysis for the project. │ │ ├── layout # Project source directory layout detection. +│ │ ├── license # License detection for the project. │ │ ├── name # Project name resolution with fallback heuristics. │ │ └── packages # Importable package discovery. │ ├── pydantic # Pydantic model utilities. @@ -260,6 +261,7 @@ ALWAYS check whether an existing function already covers your use case before im - `unignore_rules()` (`usethis._core.rule`) — Remove the given linter rules from the ignore list of the relevant tools. - `get_rules_mapping()` (`usethis._core.rule`) — Partition a list of rule codes into deptry and Ruff rule groups. - `show_backend()` (`usethis._core.show`) — Display the inferred package manager backend for the current project. +- `show_license()` (`usethis._core.show`) — Display the detected license of the current project in SPDX format. - `show_name()` (`usethis._core.show`) — Display the name of the current project. - `show_sonarqube_config()` (`usethis._core.show`) — Display the sonar-project.properties configuration for the current project. - `use_development_status()` (`usethis._core.status`) — Set the development status classifier in pyproject.toml. @@ -332,6 +334,7 @@ ALWAYS check whether an existing function already covers your use case before im - `get_layered_architectures()` (`usethis._integrations.project.imports`) — Get the suggested layers for a package. - `augment_pythonpath()` (`usethis._integrations.project.imports`) — Temporarily add a directory to the Python path. - `get_source_dir_str()` (`usethis._integrations.project.layout`) — Get the source directory as a string ('src' or '.'). +- `get_license_id()` (`usethis._integrations.project.license`) — Get the SPDX license identifier for the current project. - `get_project_name()` (`usethis._integrations.project.name`) — The project name, from pyproject.toml if available or fallback to heuristics. - `get_importable_packages()` (`usethis._integrations.project.packages`) — Get the names of packages in the source directory that can be imported. - `fancy_model_dump()` (`usethis._integrations.pydantic.dump`) — Like `pydantic.model_dump` but with bespoke formatting options. @@ -380,6 +383,7 @@ ALWAYS check whether an existing function already covers your use case before im - `readme()` (`usethis._ui.interface.readme`) — Create or update the README.md file, optionally adding badges. - `rule()` (`usethis._ui.interface.rule`) — Select, deselect, ignore, or unignore linter rules. - `backend()` (`usethis._ui.interface.show`) — Show the inferred project manager backend, e.g. 'uv' or 'none'. +- `license()` (`usethis._ui.interface.show`) — Show the project license in SPDX format. - `name()` (`usethis._ui.interface.show`) — Show the name of the project. - `sonarqube()` (`usethis._ui.interface.show`) — Show the sonar-project.properties file for SonarQube. - `spellcheck()` (`usethis._ui.interface.spellcheck`) — Add a recommended spellchecker to the project. diff --git a/docs/functions.txt b/docs/functions.txt index d1572593e..94556d116 100644 --- a/docs/functions.txt +++ b/docs/functions.txt @@ -52,6 +52,7 @@ - `unignore_rules()` (`usethis._core.rule`) — Remove the given linter rules from the ignore list of the relevant tools. - `get_rules_mapping()` (`usethis._core.rule`) — Partition a list of rule codes into deptry and Ruff rule groups. - `show_backend()` (`usethis._core.show`) — Display the inferred package manager backend for the current project. +- `show_license()` (`usethis._core.show`) — Display the detected license of the current project in SPDX format. - `show_name()` (`usethis._core.show`) — Display the name of the current project. - `show_sonarqube_config()` (`usethis._core.show`) — Display the sonar-project.properties configuration for the current project. - `use_development_status()` (`usethis._core.status`) — Set the development status classifier in pyproject.toml. @@ -124,6 +125,7 @@ - `get_layered_architectures()` (`usethis._integrations.project.imports`) — Get the suggested layers for a package. - `augment_pythonpath()` (`usethis._integrations.project.imports`) — Temporarily add a directory to the Python path. - `get_source_dir_str()` (`usethis._integrations.project.layout`) — Get the source directory as a string ('src' or '.'). +- `get_license_id()` (`usethis._integrations.project.license`) — Get the SPDX license identifier for the current project. - `get_project_name()` (`usethis._integrations.project.name`) — The project name, from pyproject.toml if available or fallback to heuristics. - `get_importable_packages()` (`usethis._integrations.project.packages`) — Get the names of packages in the source directory that can be imported. - `fancy_model_dump()` (`usethis._integrations.pydantic.dump`) — Like `pydantic.model_dump` but with bespoke formatting options. @@ -172,6 +174,7 @@ - `readme()` (`usethis._ui.interface.readme`) — Create or update the README.md file, optionally adding badges. - `rule()` (`usethis._ui.interface.rule`) — Select, deselect, ignore, or unignore linter rules. - `backend()` (`usethis._ui.interface.show`) — Show the inferred project manager backend, e.g. 'uv' or 'none'. +- `license()` (`usethis._ui.interface.show`) — Show the project license in SPDX format. - `name()` (`usethis._ui.interface.show`) — Show the name of the project. - `sonarqube()` (`usethis._ui.interface.show`) — Show the sonar-project.properties file for SonarQube. - `spellcheck()` (`usethis._ui.interface.spellcheck`) — Add a recommended spellchecker to the project. diff --git a/docs/module-tree.txt b/docs/module-tree.txt index e30d1cb60..863041f20 100644 --- a/docs/module-tree.txt +++ b/docs/module-tree.txt @@ -92,6 +92,7 @@ usethis # usethis: Automatically manage Python tooling │ │ ├── errors # Error types for project integration operations. │ │ ├── imports # Import graph analysis for the project. │ │ ├── layout # Project source directory layout detection. +│ │ ├── license # License detection for the project. │ │ ├── name # Project name resolution with fallback heuristics. │ │ └── packages # Importable package discovery. │ ├── pydantic # Pydantic model utilities. diff --git a/pyproject.toml b/pyproject.toml index 41a9c496b..9c772a79a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dynamic = [ dependencies = [ "configupdater>=3.2", "grimp>=3.14", + "identify[license]>=2.6", "packaging>=20.9", "pydantic>=2.5.0", "requests>=2.26.0", diff --git a/src/usethis/_core/show.py b/src/usethis/_core/show.py index f2764e418..5db1fa574 100644 --- a/src/usethis/_core/show.py +++ b/src/usethis/_core/show.py @@ -6,6 +6,7 @@ from usethis._backend.dispatch import get_backend from usethis._console import plain_print +from usethis._integrations.project.license import get_license_id from usethis._integrations.project.name import get_project_name from usethis._integrations.sonarqube.config import get_sonar_project_properties @@ -18,6 +19,11 @@ def show_backend(*, output_file: Path | None = None) -> None: _output(get_backend().value, output_file=output_file) +def show_license(*, output_file: Path | None = None) -> None: + """Display the detected license of the current project in SPDX format.""" + _output(get_license_id(), output_file=output_file) + + def show_name(*, output_file: Path | None = None) -> None: """Display the name of the current project.""" _output(get_project_name(), output_file=output_file) diff --git a/src/usethis/_integrations/project/errors.py b/src/usethis/_integrations/project/errors.py index 43e1ac947..28463e9f1 100644 --- a/src/usethis/_integrations/project/errors.py +++ b/src/usethis/_integrations/project/errors.py @@ -5,3 +5,7 @@ class ImportGraphBuildFailedError(UsethisError): """Raised when the import graph cannot be built.""" + + +class LicenseDetectionError(UsethisError): + """Raised when the project license cannot be determined.""" diff --git a/src/usethis/_integrations/project/license.py b/src/usethis/_integrations/project/license.py new file mode 100644 index 000000000..6f7e3d8b5 --- /dev/null +++ b/src/usethis/_integrations/project/license.py @@ -0,0 +1,163 @@ +"""License detection for the project.""" + +from __future__ import annotations + +import os + +from identify.identify import license_id + +from usethis._file.pyproject_toml.errors import PyprojectTOMLError +from usethis._file.pyproject_toml.io_ import PyprojectTOMLManager +from usethis._integrations.project.errors import LicenseDetectionError + +_CANDIDATE_LICENSE_FILENAMES = [ + "LICENSE", + "LICENSE.md", + "LICENSE.txt", + "LICENSE.rst", + "LICENCE", + "LICENCE.md", + "LICENCE.txt", + "LICENCE.rst", + "COPYING", + "COPYING.md", + "COPYING.txt", +] + +_CLASSIFIER_TO_SPDX: dict[str, str] = { + "License :: OSI Approved :: Academic Free License (AFL)": "AFL-3.0", + "License :: OSI Approved :: Apache Software License": "Apache-2.0", + "License :: OSI Approved :: Artistic License": "Artistic-2.0", + "License :: OSI Approved :: BSD License": "BSD-3-Clause", + "License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0)": "BSL-1.0", + "License :: OSI Approved :: CEA CNRS Inria Logiciel Libre License, version 2.1 (CeCILL-2.1)": "CECILL-2.1", + "License :: OSI Approved :: Eclipse Public License 1.0 (EPL-1.0)": "EPL-1.0", + "License :: OSI Approved :: Eclipse Public License 2.0 (EPL-2.0)": "EPL-2.0", + "License :: OSI Approved :: European Union Public Licence 1.1 (EUPL 1.1)": "EUPL-1.1", + "License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)": "EUPL-1.2", + "License :: OSI Approved :: GNU Affero General Public License v3": "AGPL-3.0-only", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)": "AGPL-3.0-or-later", + "License :: OSI Approved :: GNU Free Documentation License (FDL)": "GFDL-1.3-only", + "License :: OSI Approved :: GNU General Public License (GPL)": "GPL-3.0-only", + "License :: OSI Approved :: GNU General Public License v2 (GPLv2)": "GPL-2.0-only", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)": "GPL-2.0-or-later", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)": "GPL-3.0-only", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)": "GPL-3.0-or-later", + "License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)": "LGPL-2.0-only", + "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)": "LGPL-2.0-or-later", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)": "LGPL-3.0-only", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)": "LGPL-3.0-or-later", + "License :: OSI Approved :: ISC License (ISCL)": "ISC", + "License :: OSI Approved :: MIT License": "MIT", + "License :: OSI Approved :: MIT No Attribution License (MIT-0)": "MIT-0", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0", + "License :: OSI Approved :: MulanPSL v2": "MulanPSL-2.0", + "License :: OSI Approved :: The Unlicense (Unlicense)": "Unlicense", + "License :: OSI Approved :: Universal Permissive License (UPL)": "UPL-1.0", + "License :: OSI Approved :: zlib/libpng License": "Zlib", +} + + +def get_license_id() -> str: + """Get the SPDX license identifier for the current project. + + Uses heuristics in the following order: + 1. Scan common license files at the project root using the `identify` package. + 2. Read the `project.license` field from `pyproject.toml`. + 3. Check `project.classifiers` in `pyproject.toml` for license classifiers. + + Raises: + LicenseDetectionError: If the license cannot be determined. + """ + result = _get_license_from_file() + if result is not None: + return result + + result = _get_license_from_pyproject_field() + if result is not None: + return result + + result = _get_license_from_classifiers() + if result is not None: + return result + + msg = "Could not detect a project license. Add a 'LICENSE' file, or set 'project.license' in 'pyproject.toml'." + raise LicenseDetectionError(msg) + + +def _get_license_from_file() -> str | None: + """Try to detect the license from common license files at the project root.""" + for filename in _CANDIDATE_LICENSE_FILENAMES: + if os.path.isfile(filename): + spdx_id = license_id(filename) + if spdx_id is not None: + return spdx_id + return None + + +def _get_license_from_pyproject_field() -> str | None: + """Try to detect the license from pyproject.toml `project.license` field.""" + try: + pyproject = PyprojectTOMLManager().get().value + except PyprojectTOMLError: + return None + + project = pyproject.get("project") + if not isinstance(project, dict): + return None + + license_value = project.get("license") + if license_value is None: + return None + + # PEP 639: license is a string SPDX expression + if isinstance(license_value, str): + return license_value + + # PEP 621: license is a table with 'text' or 'file' key + if not isinstance(license_value, dict): + return None + + return _resolve_license_table(license_value) + + +def _resolve_license_table(license_value: dict[str, object]) -> str | None: + """Resolve a PEP 621 license table to an SPDX identifier.""" + # If it has a 'text' key, the text itself might be an SPDX identifier + text = license_value.get("text") + if isinstance(text, str) and text.strip(): + return text.strip() + + # If it has a 'file' key, try to scan that file + file_path = license_value.get("file") + if isinstance(file_path, str) and os.path.isfile(file_path): + return license_id(file_path) + + return None + + +def _get_license_from_classifiers() -> str | None: + """Try to detect the license from pyproject.toml `project.classifiers`.""" + try: + pyproject = PyprojectTOMLManager().get().value + except PyprojectTOMLError: + return None + + project = pyproject.get("project") + if not isinstance(project, dict): + return None + + classifiers = project.get("classifiers") + if not isinstance(classifiers, list): + return None + + for classifier in classifiers: + if not isinstance(classifier, str): + continue + if not classifier.startswith("License :: "): + continue + spdx_id = _CLASSIFIER_TO_SPDX.get(classifier) + if spdx_id is not None: + return spdx_id + + return None diff --git a/src/usethis/_ui/interface/show.py b/src/usethis/_ui/interface/show.py index 89a43c8c1..7d7b4f417 100644 --- a/src/usethis/_ui/interface/show.py +++ b/src/usethis/_ui/interface/show.py @@ -39,6 +39,26 @@ def backend( raise typer.Exit(code=1) from None +@app.command(help="Show the project license in SPDX format.") +def license( + offline: bool = offline_opt, + quiet: bool = quiet_opt, + output_file: Path | None = output_file_opt, +) -> None: + """Show the project license in SPDX format.""" + from usethis._config_file import files_manager + from usethis._console import err_print + from usethis._core.show import show_license + from usethis.errors import UsethisError + + with usethis_config.set(offline=offline, quiet=quiet), files_manager(): + try: + show_license(output_file=output_file) + except UsethisError as err: + err_print(err) + raise typer.Exit(code=1) from None + + @app.command(help="Show the name of the project") def name( offline: bool = offline_opt, diff --git a/tests/usethis/_integrations/project/test_license.py b/tests/usethis/_integrations/project/test_license.py new file mode 100644 index 000000000..4e0143a5e --- /dev/null +++ b/tests/usethis/_integrations/project/test_license.py @@ -0,0 +1,333 @@ +from pathlib import Path + +import pytest + +from usethis._config_file import files_manager +from usethis._integrations.project.errors import LicenseDetectionError +from usethis._integrations.project.license import ( + _get_license_from_classifiers, + _get_license_from_file, + _get_license_from_pyproject_field, + get_license_id, +) +from usethis._test import change_cwd + +_MIT_LICENSE_TEXT = """\ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + + +class TestGetLicenseId: + def test_from_license_file(self, tmp_path: Path): + # Arrange + (tmp_path / "LICENSE").write_text(_MIT_LICENSE_TEXT) + + # Act + with change_cwd(tmp_path): + result = get_license_id() + + # Assert + assert result == "MIT" + + def test_from_pyproject_field(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = "Apache-2.0"\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = get_license_id() + + # Assert + assert result == "Apache-2.0" + + def test_from_classifiers(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nclassifiers = ["License :: OSI Approved :: MIT License"]\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = get_license_id() + + # Assert + assert result == "MIT" + + def test_raises_when_no_license(self, tmp_path: Path): + # Act & Assert + with change_cwd(tmp_path), pytest.raises(LicenseDetectionError): + get_license_id() + + def test_file_takes_priority_over_pyproject(self, tmp_path: Path): + # Arrange - both a LICENSE file and pyproject.toml license field + (tmp_path / "LICENSE").write_text(_MIT_LICENSE_TEXT) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = "Apache-2.0"\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = get_license_id() + + # Assert - file takes priority + assert result == "MIT" + + +class TestGetLicenseFromFile: + def test_license_file(self, tmp_path: Path): + # Arrange + (tmp_path / "LICENSE").write_text(_MIT_LICENSE_TEXT) + + # Act + with change_cwd(tmp_path): + result = _get_license_from_file() + + # Assert + assert result == "MIT" + + def test_license_md_file(self, tmp_path: Path): + # Arrange + (tmp_path / "LICENSE.md").write_text(_MIT_LICENSE_TEXT) + + # Act + with change_cwd(tmp_path): + result = _get_license_from_file() + + # Assert + assert result == "MIT" + + def test_licence_spelling(self, tmp_path: Path): + # Arrange + (tmp_path / "LICENCE").write_text(_MIT_LICENSE_TEXT) + + # Act + with change_cwd(tmp_path): + result = _get_license_from_file() + + # Assert + assert result == "MIT" + + def test_no_license_file(self, tmp_path: Path): + # Act + with change_cwd(tmp_path): + result = _get_license_from_file() + + # Assert + assert result is None + + def test_unrecognized_license_content(self, tmp_path: Path): + # Arrange - file exists but identify can't determine the license + (tmp_path / "LICENSE").write_text("This is not a recognized license.\n") + + # Act + with change_cwd(tmp_path): + result = _get_license_from_file() + + # Assert + assert result is None + + +class TestGetLicenseFromPyprojectField: + def test_spdx_string(self, tmp_path: Path): + # Arrange - PEP 639 style: license is a plain SPDX string + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = "MIT"\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_pyproject_field() + + # Assert + assert result == "MIT" + + def test_table_with_text(self, tmp_path: Path): + # Arrange - PEP 621 style: license table with text key + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = {text = "MIT"}\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_pyproject_field() + + # Assert + assert result == "MIT" + + def test_table_with_file(self, tmp_path: Path): + # Arrange - PEP 621 style: license table with file key + (tmp_path / "LICENSE").write_text(_MIT_LICENSE_TEXT) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = {file = "LICENSE"}\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_pyproject_field() + + # Assert + assert result == "MIT" + + def test_no_pyproject(self, tmp_path: Path): + # Act + with change_cwd(tmp_path): + result = _get_license_from_pyproject_field() + + # Assert + assert result is None + + def test_no_project_section(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text("[tool]\n") + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_pyproject_field() + + # Assert + assert result is None + + def test_no_license_field(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n') + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_pyproject_field() + + # Assert + assert result is None + + def test_table_with_file_not_found(self, tmp_path: Path): + # Arrange - file key points to non-existent file + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = {file = "MISSING"}\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_pyproject_field() + + # Assert + assert result is None + + def test_table_with_empty_text(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = {text = ""}\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_pyproject_field() + + # Assert + assert result is None + + +class TestGetLicenseFromClassifiers: + def test_mit_classifier(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nclassifiers = ["License :: OSI Approved :: MIT License"]\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_classifiers() + + # Assert + assert result == "MIT" + + def test_apache_classifier(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nclassifiers = ["License :: OSI Approved :: Apache Software License"]\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_classifiers() + + # Assert + assert result == "Apache-2.0" + + def test_gpl3_classifier(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nclassifiers = ["License :: OSI Approved :: GNU General Public License v3 (GPLv3)"]\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_classifiers() + + # Assert + assert result == "GPL-3.0-only" + + def test_no_classifiers(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n') + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_classifiers() + + # Assert + assert result is None + + def test_no_license_classifier(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nclassifiers = ["Programming Language :: Python :: 3"]\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_classifiers() + + # Assert + assert result is None + + def test_unknown_license_classifier(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nclassifiers = ["License :: Other/Proprietary License"]\n' + ) + + # Act + with change_cwd(tmp_path), files_manager(): + result = _get_license_from_classifiers() + + # Assert + assert result is None + + def test_no_pyproject(self, tmp_path: Path): + # Act + with change_cwd(tmp_path): + result = _get_license_from_classifiers() + + # Assert + assert result is None diff --git a/tests/usethis/_ui/interface/test_show.py b/tests/usethis/_ui/interface/test_show.py index 4a5982d4b..5af7c53b5 100644 --- a/tests/usethis/_ui/interface/test_show.py +++ b/tests/usethis/_ui/interface/test_show.py @@ -5,6 +5,30 @@ from usethis._types.backend import BackendEnum from usethis._ui.interface.show import app +_MIT_LICENSE_TEXT = """\ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + class TestBackend: def test_uv_backend(self, tmp_path: Path): @@ -94,6 +118,61 @@ def test_output_file(self, tmp_path: Path): assert output_file.read_text(encoding="utf-8") == "fun\n" +class TestLicense: + def test_from_license_file(self, tmp_path: Path): + # Arrange + (tmp_path / "LICENSE").write_text(_MIT_LICENSE_TEXT) + + # Act + runner = CliRunner() + with change_cwd(tmp_path): + result = runner.invoke_safe(app, ["license"]) + + # Assert + assert result.exit_code == 0, result.output + assert result.output == "MIT\n" + + def test_from_pyproject_field(self, tmp_path: Path): + # Arrange + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "test"\nlicense = "Apache-2.0"\n' + ) + + # Act + runner = CliRunner() + with change_cwd(tmp_path): + result = runner.invoke_safe(app, ["license"]) + + # Assert + assert result.exit_code == 0, result.output + assert result.output == "Apache-2.0\n" + + def test_no_license(self, tmp_path: Path): + # Act + runner = CliRunner() + with change_cwd(tmp_path): + result = runner.invoke_safe(app, ["license"]) + + # Assert + assert result.exit_code == 1, result.output + + def test_output_file(self, tmp_path: Path): + # Arrange + (tmp_path / "LICENSE").write_text(_MIT_LICENSE_TEXT) + output_file = tmp_path / "license.txt" + + # Act + runner = CliRunner() + with change_cwd(tmp_path): + result = runner.invoke_safe( + app, ["license", "--output-file", str(output_file)] + ) + + # Assert + assert result.exit_code == 0, result.output + assert output_file.read_text(encoding="utf-8") == "MIT\n" + + class TestSonarqube: def test_runs(self, tmp_path: Path): # Arrange diff --git a/uv.lock b/uv.lock index fd4f51054..9a602ba5e 100644 --- a/uv.lock +++ b/uv.lock @@ -615,6 +615,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[package.optional-dependencies] +license = [ + { name = "ukkonen" }, +] + [[package]] name = "idna" version = "3.10" @@ -1666,6 +1680,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "ukkonen" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/94/80c733c4b72efb93250dfadf03cc110a4310cd4e2077280af637f0882801/ukkonen-1.1.0.tar.gz", hash = "sha256:fc62ce0a5b5d57198d4dc0c6ebca406b41afca2b6681af5159a6709b47286377", size = 4070, upload-time = "2026-01-25T23:01:30.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/2c/57cd06dd63fdf9511b2675d76b53136f4ad12c5e1927a4d7db7cfe47e107/ukkonen-1.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:801246f054be80be296dad60fda8f23ef335c079d2d72195a84513c6c61519bc", size = 7785, upload-time = "2026-01-25T23:07:26.693Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/c613cf780a7bdc2fb0d11496c8a2e526c7c4bfa40a956753e4a86f5b7518/ukkonen-1.1.0-cp310-abi3-macosx_13_0_x86_64.whl", hash = "sha256:0b08daece3d2e7d7323a84e683619ff3409e50a53e5f606ebda3c12e93c81f7c", size = 7386, upload-time = "2026-01-25T23:07:27.828Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/6b58da9d3fdc49e61132d23b7848ce96fe8db001635bfed54566a6856257/ukkonen-1.1.0-cp310-abi3-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:43f755181149a085ffd934422017be6f8159fde47501fc396fbc0558536a1fcc", size = 29844, upload-time = "2026-01-25T23:07:28.97Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/9fd463ac96d6269b4e3d465f31863df56f400fb35a50ca5cf62f8516967c/ukkonen-1.1.0-cp310-abi3-win32.whl", hash = "sha256:b6401f6a96fbf7c4ff9bdd95e3f2aad94a245231af9608796f23f5bbe00fa125", size = 11286, upload-time = "2026-01-25T23:07:31.537Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f6/3a31457b731c750accbdf15177ab09d7cca312b88c6a2dedc979445a0d1d/ukkonen-1.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:e1b1e65f43a5fdbc1e073e58a0fe666f931b9cf199ee18425975aeedb35c760b", size = 11559, upload-time = "2026-01-25T23:07:32.568Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1681,6 +1711,7 @@ source = { editable = "." } dependencies = [ { name = "configupdater" }, { name = "grimp" }, + { name = "identify", extra = ["license"] }, { name = "packaging" }, { name = "pydantic" }, { name = "requests" }, @@ -1728,6 +1759,7 @@ uv = [ requires-dist = [ { name = "configupdater", specifier = ">=3.2" }, { name = "grimp", specifier = ">=3.14" }, + { name = "identify", extras = ["license"], specifier = ">=2.6" }, { name = "packaging", specifier = ">=20.9" }, { name = "pydantic", specifier = ">=2.5.0" }, { name = "requests", specifier = ">=2.26.0" },