Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/functions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/module-tree.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/usethis/_core/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/usethis/_integrations/project/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
163 changes: 163 additions & 0 deletions src/usethis/_integrations/project/license.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions src/usethis/_ui/interface/show.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading