diff --git a/src/poetry/core/factory.py b/src/poetry/core/factory.py index c70175b6c..d1273b295 100644 --- a/src/poetry/core/factory.py +++ b/src/poetry/core/factory.py @@ -12,7 +12,6 @@ from packaging.utils import canonicalize_name -from poetry.core.utils.helpers import combine_unicode from poetry.core.utils.helpers import readme_content_type @@ -20,10 +19,10 @@ from collections.abc import Mapping from poetry.core.packages.dependency import Dependency - from poetry.core.packages.dependency_group import DependencyGroup from poetry.core.packages.project_package import ProjectPackage from poetry.core.poetry import Poetry - from poetry.core.spdx.license import License + from poetry.core.pyproject.formats.content_format import ContentFormat + from poetry.core.pyproject.toml import PyProjectTOML DependencyConstraint = Union[str, Dict[str, Any]] DependencyConfig = Mapping[ @@ -39,35 +38,54 @@ class Factory: Factory class to create various elements needed by Poetry. """ + def __init__(self) -> None: + from poetry.core.pyproject.formats.legacy_content_format import ( + LegacyContentFormat, + ) + from poetry.core.pyproject.formats.standard_content_format import ( + StandardContentFormat, + ) + from poetry.core.pyproject.toml import PyProjectTOML + + self._default_format: type[ContentFormat] = LegacyContentFormat + self._content_formats: dict[str, type[ContentFormat]] = { + "legacy": LegacyContentFormat, + "standard": StandardContentFormat, + } + + self._pyproject_class = PyProjectTOML + + def register_content_format( + self, name: str, content_format: type[ContentFormat] + ) -> None: + self._content_formats[name] = content_format + def create_poetry( self, cwd: Path | None = None, with_groups: bool = True ) -> Poetry: from poetry.core.poetry import Poetry - from poetry.core.pyproject.toml import PyProjectTOML poetry_file = self.locate(cwd) - local_config = PyProjectTOML(path=poetry_file).poetry_config + pyproject = self.create_pyproject(poetry_file) + + if not pyproject.is_poetry_project(): + raise RuntimeError(f"The project at {poetry_file} is not a Poetry project") # Checking validity - check_result = self.validate(local_config) - if check_result["errors"]: + check_result = pyproject.content.validate(strict=False) + if check_result.errors: message = "" - for error in check_result["errors"]: + for error in check_result.errors: message += f" - {error}\n" raise RuntimeError("The Poetry configuration is invalid:\n" + message) # Load package - name = local_config["name"] - assert isinstance(name, str) - version = local_config["version"] - assert isinstance(version, str) - package = self.get_package(name, version) - package = self.configure_package( - package, local_config, poetry_file.parent, with_groups=with_groups + package = pyproject.content.to_package( + root=poetry_file.parent, with_groups=with_groups ) - return Poetry(poetry_file, local_config, package) + return Poetry(poetry_file, pyproject, package) @classmethod def get_package(cls, name: str, version: str) -> ProjectPackage: @@ -75,150 +93,29 @@ def get_package(cls, name: str, version: str) -> ProjectPackage: return ProjectPackage(name, version) - @classmethod - def _add_package_group_dependencies( - cls, - package: ProjectPackage, - group: str | DependencyGroup, - dependencies: DependencyConfig, - ) -> None: - from poetry.core.packages.dependency_group import MAIN_GROUP - - if isinstance(group, str): - if package.has_dependency_group(group): - group = package.dependency_group(group) - else: - from poetry.core.packages.dependency_group import DependencyGroup - - group = DependencyGroup(group) - - for name, constraints in dependencies.items(): - _constraints = ( - constraints if isinstance(constraints, list) else [constraints] - ) - for _constraint in _constraints: - if name.lower() == "python": - if group.name == MAIN_GROUP and isinstance(_constraint, str): - package.python_versions = _constraint - continue - - group.add_dependency( - cls.create_dependency( - name, - _constraint, - groups=[group.name], - root_dir=package.root_dir, - ) - ) - - package.add_dependency_group(group) - - @classmethod - def configure_package( - cls, - package: ProjectPackage, - config: dict[str, Any], - root: Path, - with_groups: bool = True, - ) -> ProjectPackage: - from poetry.core.packages.dependency import Dependency - from poetry.core.packages.dependency_group import MAIN_GROUP - from poetry.core.packages.dependency_group import DependencyGroup - from poetry.core.spdx.helpers import license_by_id - - package.root_dir = root - - for author in config["authors"]: - package.authors.append(combine_unicode(author)) - - for maintainer in config.get("maintainers", []): - package.maintainers.append(combine_unicode(maintainer)) - - package.description = config.get("description", "") - package.homepage = config.get("homepage") - package.repository_url = config.get("repository") - package.documentation_url = config.get("documentation") - try: - license_: License | None = license_by_id(config.get("license", "")) - except ValueError: - license_ = None - - package.license = license_ - package.keywords = config.get("keywords", []) - package.classifiers = config.get("classifiers", []) - - if "readme" in config: - if isinstance(config["readme"], str): - package.readmes = (root / config["readme"],) - else: - package.readmes = tuple(root / readme for readme in config["readme"]) - - if "dependencies" in config: - cls._add_package_group_dependencies( - package=package, group=MAIN_GROUP, dependencies=config["dependencies"] - ) - - if with_groups and "group" in config: - for group_name, group_config in config["group"].items(): - group = DependencyGroup( - group_name, optional=group_config.get("optional", False) - ) - cls._add_package_group_dependencies( - package=package, - group=group, - dependencies=group_config["dependencies"], - ) - - if with_groups and "dev-dependencies" in config: - cls._add_package_group_dependencies( - package=package, group="dev", dependencies=config["dev-dependencies"] - ) + def create_pyproject(self, path: Path) -> PyProjectTOML: + content_format = self.guess_content_format(path) - extras = config.get("extras", {}) - for extra_name, requirements in extras.items(): - extra_name = canonicalize_name(extra_name) - package.extras[extra_name] = [] + pyproject = self._pyproject_class(path, content_format) - # Checking for dependency - for req in requirements: - req = Dependency(req, "*") + return pyproject - for dep in package.requires: - if dep.name == req.name: - dep.in_extras.append(extra_name) - package.extras[extra_name].append(dep) + def guess_content_format(self, path: Path) -> type[ContentFormat]: + if not path.exists(): + return self._default_format - if "build" in config: - build = config["build"] - if not isinstance(build, dict): - build = {"script": build} - package.build_config = build or {} + from poetry.core.utils._compat import tomllib - if "include" in config: - package.include = [] + data = tomllib.loads(path.read_text(encoding="utf-8")) - for include in config["include"]: - if not isinstance(include, dict): - include = {"path": include} + for name, fmt in self._content_formats.items(): + if fmt.supports(data): + logger.debug("Using the %s format", name) + return fmt - formats = include.get("format", []) - if formats and not isinstance(formats, list): - formats = [formats] - include["format"] = formats - - package.include.append(include) - - if "exclude" in config: - package.exclude = config["exclude"] - - if "packages" in config: - package.packages = config["packages"] - - # Custom urls - if "urls" in config: - package.custom_urls = config["urls"] - - return package + raise RuntimeError( + "Unable to determine the content format of the pyproject.toml file" + ) @classmethod def create_dependency( diff --git a/src/poetry/core/json/schemas/poetry-pep621-schema.json b/src/poetry/core/json/schemas/poetry-pep621-schema.json new file mode 100644 index 000000000..e5854c881 --- /dev/null +++ b/src/poetry/core/json/schemas/poetry-pep621-schema.json @@ -0,0 +1,693 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "name": "Package", + "type": "object", + "required": [ + "project" + ], + "properties": { + "project": { + "$ref": "#/definitions/project" + }, + "tool": { + "type": "object", + "properties": { + "poetry": { + "$ref": "#/definitions/poetry-config" + } + } + } + }, + "definitions": { + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "type": "string", + "description": "Package name." + }, + "version": { + "type": "string", + "description": "Package version." + }, + "description": { + "type": "string", + "description": "Short package description.", + "pattern": "^[^\n]*$" + }, + "keywords": { + "type": "array", + "items": { + "type": "string", + "description": "A tag/keyword that this package relates to." + } + }, + "license": { + "$ref": "#/definitions/license" + }, + "authors": { + "$ref": "#/definitions/authors" + }, + "maintainers": { + "$ref": "#/definitions/maintainers" + }, + "readme": { + "$ref": "#/definitions/readme" + }, + "requires-python": { + "type": "string", + "description": "The Python version requirements of the project." + }, + "classifiers": { + "type": "array", + "description": "A list of trove classifiers." + }, + "urls": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "string", + "description": "The full url of the custom url." + } + } + }, + "dependencies": { + "type": "array", + "description": "A list of runtime dependencies in the PEP 508 format", + "items": { + "type": "string" + } + }, + "optional-dependencies": { + "type": "object", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "extras": { + "type": "object", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "scripts": { + "type": "object", + "description": "A hash of scripts to be installed.", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "type": "string" + } + } + }, + "gui-scripts": { + "type": "object", + "description": "A hash of scripts to be installed.", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "type": "string" + } + } + }, + "entry-points": { + "type": "object", + "description": "A hash of hashes representing plugins", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "type": "object", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "type": "string" + } + } + } + } + } + } + }, + "poetry-config": { + "type": "object", + "additionalProperties": true, + "packages": { + "type": "array", + "description": "A list of packages to include in the final distribution.", + "items": { + "type": "object", + "description": "Information about where the package resides.", + "additionalProperties": false, + "required": [ + "include" + ], + "properties": { + "include": { + "$ref": "#/definitions/include-path" + }, + "from": { + "type": "string", + "description": "Where the source directory of the package resides." + }, + "format": { + "$ref": "#/definitions/package-formats" + } + } + } + }, + "include": { + "type": "array", + "description": "A list of files and folders to include.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/include-path" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "$ref": "#/definitions/include-path" + }, + "format": { + "$ref": "#/definitions/package-formats" + } + } + } + ] + } + }, + "exclude": { + "type": "array", + "description": "A list of files and folders to exclude." + }, + "group": { + "type": "object", + "description": "This represents groups of dependencies", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "type": "object", + "description": "This represents a single dependency group", + "required": [ + "dependencies" + ], + "properties": { + "optional": { + "type": "boolean", + "description": "Whether the dependency group is optional or not" + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": true + } + } + }, + "build": { + "$ref": "#/definitions/build-section" + } + }, + "license": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "file": { + "type": "string" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + } + } + } + ] + }, + "authors": { + "type": "array", + "description": "List of authors that contributed to the package. This is typically the main maintainers, not the full list.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + }, + "maintainers": { + "type": "array", + "description": "List of maintainers, other than the original author(s), that upkeep the package.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + }, + "readme": { + "oneOf": [ + { + "type": "string", + "description": "Relative path to the README file" + }, + { + "oneOf": [ + { + "type": "object", + "description": "Readme with file and content type", + "properties": { + "file": { + "type": "string", + "description": "The relative path to the readme file" + }, + "content-type": { + "type": "string", + "description": "The content type of the README file content" + } + } + }, + { + "type": "object", + "description": "Readme with full description and content type", + "properties": { + "file": { + "type": "string", + "description": "The full content of the description" + }, + "content-type": { + "type": "string", + "description": "The content type of the description" + } + } + } + ] + } + ] + }, + "include-path": { + "type": "string", + "description": "Path to file or directory to include." + }, + "package-format": { + "type": "string", + "enum": [ + "sdist", + "wheel" + ], + "description": "A Python packaging format." + }, + "package-formats": { + "oneOf": [ + { + "$ref": "#/definitions/package-format" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/package-format" + } + } + ], + "description": "The format(s) for which the package must be included." + }, + "dependencies": { + "type": "object", + "patternProperties": { + "^[a-zA-Z-_.0-9]+$": { + "oneOf": [ + { + "$ref": "#/definitions/dependency" + }, + { + "$ref": "#/definitions/long-dependency" + }, + { + "$ref": "#/definitions/git-dependency" + }, + { + "$ref": "#/definitions/file-dependency" + }, + { + "$ref": "#/definitions/path-dependency" + }, + { + "$ref": "#/definitions/url-dependency" + }, + { + "$ref": "#/definitions/multiple-constraints-dependency" + } + ] + } + } + }, + "dependency": { + "type": "string", + "description": "The constraint of the dependency." + }, + "long-dependency": { + "type": "object", + "required": [ + "version" + ], + "additionalProperties": false, + "properties": { + "version": { + "type": "string", + "description": "The constraint of the dependency." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "allow-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "allows-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + }, + "source": { + "type": "string", + "description": "The exclusive source used to search for this dependency." + } + } + }, + "git-dependency": { + "type": "object", + "required": [ + "git" + ], + "additionalProperties": false, + "properties": { + "git": { + "type": "string", + "description": "The url of the git repository.", + "format": "uri" + }, + "branch": { + "type": "string", + "description": "The branch to checkout." + }, + "tag": { + "type": "string", + "description": "The tag to checkout." + }, + "rev": { + "type": "string", + "description": "The revision to checkout." + }, + "subdirectory": { + "type": "string", + "description": "The relative path to the directory where the package is located." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "allow-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "allows-prereleases": { + "type": "boolean", + "description": "Whether the dependency allows prereleases or not." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + }, + "develop": { + "type": "boolean", + "description": "Whether to install the dependency in development mode." + } + } + }, + "file-dependency": { + "type": "object", + "required": [ + "file" + ], + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "description": "The path to the file." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + } + } + }, + "path-dependency": { + "type": "object", + "required": [ + "path" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "The path to the dependency." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + }, + "develop": { + "type": "boolean", + "description": "Whether to install the dependency in development mode." + } + } + }, + "url-dependency": { + "type": "object", + "required": [ + "url" + ], + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "The url to the file." + }, + "python": { + "type": "string", + "description": "The python versions for which the dependency should be installed." + }, + "platform": { + "type": "string", + "description": "The platform(s) for which the dependency should be installed." + }, + "markers": { + "type": "string", + "description": "The PEP 508 compliant environment markers for which the dependency should be installed." + }, + "optional": { + "type": "boolean", + "description": "Whether the dependency is optional or not." + }, + "extras": { + "type": "array", + "description": "The required extras for this dependency.", + "items": { + "type": "string" + } + } + } + }, + "multiple-constraints-dependency": { + "type": "array", + "minItems": 1, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dependency" + }, + { + "$ref": "#/definitions/long-dependency" + }, + { + "$ref": "#/definitions/git-dependency" + }, + { + "$ref": "#/definitions/file-dependency" + }, + { + "$ref": "#/definitions/path-dependency" + }, + { + "$ref": "#/definitions/url-dependency" + } + ] + } + }, + "repository": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the repository" + }, + "url": { + "type": "string", + "description": "The url of the repository", + "format": "uri" + }, + "default": { + "type": "boolean", + "description": "Make this repository the default (disable PyPI)" + }, + "secondary": { + "type": "boolean", + "description": "Declare this repository as secondary, i.e. it will only be looked up last for packages." + }, + "links": { + "type": "boolean", + "description": "Declare this as a link source. Links at uri/path can point to sdist or bdist archives." + }, + "indexed": { + "type": "boolean", + "description": "For PEP 503 simple API repositories, pre-fetch and index the available packages. (experimental)" + } + } + }, + "build-script": { + "type": "string", + "description": "The python script file used to build extensions." + }, + "build-config": { + "type": "object", + "description": "Build specific configurations.", + "additionalProperties": false, + "properties": { + "generate-setup-file": { + "type": "boolean", + "description": "Generate and include a setup.py file in sdist.", + "default": true + }, + "script": { + "$ref": "#/definitions/build-script" + } + } + }, + "build-section": { + "oneOf": [ + { + "$ref": "#/definitions/build-script" + }, + { + "$ref": "#/definitions/build-config" + } + ] + } + } +} diff --git a/src/poetry/core/masonry/builders/builder.py b/src/poetry/core/masonry/builders/builder.py index 0b450e3e5..15f4ae69f 100644 --- a/src/poetry/core/masonry/builders/builder.py +++ b/src/poetry/core/masonry/builders/builder.py @@ -278,7 +278,7 @@ def convert_entry_points(self) -> dict[str, list[str]]: result = defaultdict(list) # Scripts -> Entry points - for name, specification in self._poetry.local_config.get("scripts", {}).items(): + for name, specification in self._poetry.package.scripts.items(): if isinstance(specification, str): # TODO: deprecate this in favour or reference specification = {"reference": specification, "type": "console"} @@ -308,7 +308,7 @@ def convert_entry_points(self) -> dict[str, list[str]]: result["console_scripts"].append(f"{name} = {reference}{extras}") # Plugins -> entry points - plugins = self._poetry.local_config.get("plugins", {}) + plugins = self._poetry.package.entrypoints for groupname, group in plugins.items(): for name, specification in sorted(group.items()): result[groupname].append(f"{name} = {specification}") @@ -321,7 +321,7 @@ def convert_entry_points(self) -> dict[str, list[str]]: def convert_script_files(self) -> list[Path]: script_files: list[Path] = [] - for name, specification in self._poetry.local_config.get("scripts", {}).items(): + for name, specification in self._poetry.package.scripts.items(): if isinstance(specification, dict) and specification.get("type") == "file": source = specification["reference"] diff --git a/src/poetry/core/masonry/builders/sdist.py b/src/poetry/core/masonry/builders/sdist.py index ae0222cf0..3efad9a2e 100644 --- a/src/poetry/core/masonry/builders/sdist.py +++ b/src/poetry/core/masonry/builders/sdist.py @@ -21,7 +21,6 @@ if TYPE_CHECKING: - from collections.abc import Iterable from collections.abc import Iterator from tarfile import TarInfo @@ -330,12 +329,11 @@ def find_files_to_add(self, exclude_build: bool = False) -> set[BuildIncludeFile additional_files.add(Path("pyproject.toml")) # add readme files if specified - if "readme" in self._poetry.local_config: - readme: str | Iterable[str] = self._poetry.local_config["readme"] - if isinstance(readme, str): - additional_files.add(Path(readme)) - else: - additional_files.update(Path(r) for r in readme) + if self._poetry.package.readmes: + for readme in self._poetry.package.readmes: + additional_files.add(readme) + elif self._poetry.package.readme: + additional_files.add(self._poetry.package.readme) for additional_file in additional_files: file = BuildIncludeFile( diff --git a/src/poetry/core/masonry/builders/wheel.py b/src/poetry/core/masonry/builders/wheel.py index 25312ac23..ba50582ea 100644 --- a/src/poetry/core/masonry/builders/wheel.py +++ b/src/poetry/core/masonry/builders/wheel.py @@ -242,10 +242,7 @@ def prepare_metadata(self, metadata_directory: Path) -> Path: dist_info = metadata_directory / self.dist_info dist_info.mkdir(parents=True, exist_ok=True) - if ( - "scripts" in self._poetry.local_config - or "plugins" in self._poetry.local_config - ): + if self._poetry.package.scripts or self._poetry.package.entrypoints: with (dist_info / "entry_points.txt").open( "w", encoding="utf-8", newline="\n" ) as f: diff --git a/src/poetry/core/packages/package.py b/src/poetry/core/packages/package.py index bad40c088..ded2f143a 100644 --- a/src/poetry/core/packages/package.py +++ b/src/poetry/core/packages/package.py @@ -105,6 +105,9 @@ def __init__( self.documentation_url: str | None = None self.keywords: list[str] = [] self._license: License | None = None + self._readme: Path | None = None + self.readme_content_type: str | None = None + self.readme_content: str | None = None self.readmes: tuple[Path, ...] = () self.extras: dict[NormalizedName, list[Dependency]] = {} @@ -397,27 +400,14 @@ def category(self, category: str) -> None: @property def readme(self) -> Path | None: - warnings.warn( - ( - "`readme` is deprecated: you are getting only the first readme file." - " Please use the plural form `readmes`." - ), - DeprecationWarning, - stacklevel=2, - ) - return next(iter(self.readmes), None) + if self._readme is None and self.readmes: + return next(iter(self.readmes), None) + + return self._readme @readme.setter def readme(self, path: Path) -> None: - warnings.warn( - ( - "`readme` is deprecated. Please assign a tuple to the plural form" - " `readmes`." - ), - DeprecationWarning, - stacklevel=2, - ) - self.readmes = (path,) + self._readme = path @property def yanked(self) -> bool: diff --git a/src/poetry/core/packages/project_package.py b/src/poetry/core/packages/project_package.py index 7cf82fd24..60c37f7b6 100644 --- a/src/poetry/core/packages/project_package.py +++ b/src/poetry/core/packages/project_package.py @@ -41,6 +41,9 @@ def __init__( self.include: list[dict[str, Any]] = [] self.exclude: list[dict[str, Any]] = [] self.custom_urls: dict[str, str] = {} + self.scripts: dict[str, str | dict[str, Any]] = {} + self.gui_scripts: dict[str, str] = {} + self.entrypoints: dict[str, dict[str, str | dict[str, str]]] = {} if self._python_versions == "*": self._python_constraint = parse_constraint("~2.7 || >=3.4") diff --git a/src/poetry/core/packages/utils/utils.py b/src/poetry/core/packages/utils/utils.py index 97229517b..1a07e0d9a 100644 --- a/src/poetry/core/packages/utils/utils.py +++ b/src/poetry/core/packages/utils/utils.py @@ -18,7 +18,6 @@ from poetry.core.constraints.version import Version from poetry.core.constraints.version import VersionRange from poetry.core.constraints.version import parse_marker_version_constraint -from poetry.core.pyproject.toml import PyProjectTOML from poetry.core.version.markers import SingleMarker from poetry.core.version.markers import SingleMarkerLike from poetry.core.version.markers import dnf @@ -33,7 +32,6 @@ # this as `dict[str, ...]` ConvertedMarkers = Dict[str, List[List[Tuple[str, str]]]] - BZ2_EXTENSIONS = (".tar.bz2", ".tbz") XZ_EXTENSIONS = (".tar.xz", ".txz", ".tlz", ".tar.lz", ".tar.lzma") ZIP_EXTENSIONS = (".zip", ".whl") @@ -125,6 +123,8 @@ def is_python_project(path: Path) -> bool: if not path.is_dir(): return False + from poetry.core.pyproject.toml import PyProjectTOML + setup_py = path / "setup.py" setup_cfg = path / "setup.cfg" setuptools_project = setup_py.exists() or setup_cfg.exists() diff --git a/src/poetry/core/poetry.py b/src/poetry/core/poetry.py index 7f6a2e764..6dc88ce15 100644 --- a/src/poetry/core/poetry.py +++ b/src/poetry/core/poetry.py @@ -3,26 +3,23 @@ from typing import TYPE_CHECKING from typing import Any -from poetry.core.pyproject.toml import PyProjectTOML - if TYPE_CHECKING: from pathlib import Path from poetry.core.packages.project_package import ProjectPackage + from poetry.core.pyproject.toml import PyProjectTOML class Poetry: def __init__( self, file: Path, - local_config: dict[str, Any], + pyproject: PyProjectTOML, package: ProjectPackage, - pyproject_type: type[PyProjectTOML] = PyProjectTOML, ) -> None: - self._pyproject = pyproject_type(file) + self._pyproject = pyproject self._package = package - self._local_config = local_config @property def pyproject(self) -> PyProjectTOML: @@ -38,7 +35,7 @@ def package(self) -> ProjectPackage: @property def local_config(self) -> dict[str, Any]: - return self._local_config + return self._pyproject.poetry_config def get_project_config(self, config: str, default: Any = None) -> Any: - return self._local_config.get("config", {}).get(config, default) + return self.local_config.get("config", {}).get(config, default) diff --git a/src/poetry/core/pyproject/formats/__init__.py b/src/poetry/core/pyproject/formats/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/poetry/core/pyproject/formats/content_format.py b/src/poetry/core/pyproject/formats/content_format.py new file mode 100644 index 000000000..4f611a4d5 --- /dev/null +++ b/src/poetry/core/pyproject/formats/content_format.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import TYPE_CHECKING +from typing import Any + + +if TYPE_CHECKING: + from pathlib import Path + + from poetry.core.packages.project_package import ProjectPackage + from poetry.core.pyproject.formats.validation_result import ValidationResult + + +class ContentFormat(ABC): + def __init__(self, content: dict[str, Any]) -> None: + self._content = content + + @classmethod + @abstractmethod + def supports(cls, content: dict[str, Any]) -> bool: + ... + + @abstractmethod + def validate(self, strict: bool = False) -> ValidationResult: + ... + + @abstractmethod + def to_package(self, root: Path, with_groups: bool = True) -> ProjectPackage: + ... + + @property + @abstractmethod + def hash_content(self) -> dict[str, Any]: + ... + + @property + @abstractmethod + def poetry_config(self) -> dict[str, Any]: + """ + The custom poetry configuration (i.e. the parts in [tool.poetry] + that are not related to the package) + """ + ... + + @abstractmethod + def metadata(self, name: str, default: Any = None) -> Any: + """ + Find a standard metadata section in the content. + """ + ... + + def is_empty(self) -> bool: + return not self._content diff --git a/src/poetry/core/pyproject/formats/legacy_content_format.py b/src/poetry/core/pyproject/formats/legacy_content_format.py new file mode 100644 index 000000000..dbf8fb9de --- /dev/null +++ b/src/poetry/core/pyproject/formats/legacy_content_format.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Any +from typing import Dict +from typing import List +from typing import Mapping +from typing import Union + +from packaging.utils import canonicalize_name + +from poetry.core.json import validate_object +from poetry.core.packages.project_package import ProjectPackage +from poetry.core.pyproject.formats.content_format import ContentFormat +from poetry.core.pyproject.formats.validation_result import ValidationResult +from poetry.core.utils.helpers import combine_unicode +from poetry.core.utils.helpers import readme_content_type + + +if TYPE_CHECKING: + from pathlib import Path + + from poetry.core.packages.dependency import Dependency + from poetry.core.packages.dependency_group import DependencyGroup + from poetry.core.spdx.license import License + + DependencyConstraint = Union[str, Dict[str, Any]] + DependencyConfig = Mapping[ + str, Union[List[DependencyConstraint], DependencyConstraint] + ] + + +class LegacyContentFormat(ContentFormat): + @classmethod + def supports(cls, content: dict[str, Any]) -> bool: + return ( + "project" not in content + and "tool" in content + and "poetry" in content["tool"] + ) + + def validate(self, strict: bool = False) -> ValidationResult: + result = ValidationResult([], []) + + # We are only interested in the [tool.poetry] table + config = self._content.get("tool", {}).get("poetry", {}) + + # Schema validation errors + validation_errors = validate_object(config, "poetry-schema") + + result.errors += validation_errors + + if strict: + # If strict, check the file more thoroughly + if "dependencies" in config: + python_versions = config["dependencies"]["python"] + if python_versions == "*": + result.warnings.append( + "A wildcard Python dependency is ambiguous. " + "Consider specifying a more explicit one." + ) + + for name, constraint in config["dependencies"].items(): + if not isinstance(constraint, dict): + continue + + if "allows-prereleases" in constraint: + result.warnings.append( + f'The "{name}" dependency specifies ' + 'the "allows-prereleases" property, which is deprecated. ' + 'Use "allow-prereleases" instead.' + ) + + # Checking for scripts with extras + if "scripts" in config: + scripts = config["scripts"] + for name, script in scripts.items(): + if not isinstance(script, dict): + continue + + extras = script["extras"] + for extra in extras: + if extra not in config["extras"]: + result.errors.append( + f'Script "{name}" requires extra "{extra}" which is not' + " defined." + ) + + # Checking types of all readme files (must match) + if "readme" in config and not isinstance(config["readme"], str): + readme_types = {readme_content_type(r) for r in config["readme"]} + if len(readme_types) > 1: + result.errors.append( + "Declared README files must be of same type: found" + f" {', '.join(sorted(readme_types))}" + ) + + return result + + def to_package(self, root: Path, with_groups: bool = True) -> ProjectPackage: + from poetry.core.packages.dependency import Dependency + from poetry.core.packages.dependency_group import MAIN_GROUP + from poetry.core.packages.dependency_group import DependencyGroup + from poetry.core.spdx.helpers import license_by_id + + config = self._content["tool"]["poetry"] + + package = ProjectPackage(config["name"], config["version"]) + package.root_dir = root + + for author in config.get("authors", []): + package.authors.append(combine_unicode(author)) + + for maintainer in config.get("maintainers", []): + package.maintainers.append(combine_unicode(maintainer)) + + package.description = config["description"] + package.homepage = config.get("homepage") + package.repository_url = config.get("repository") + package.documentation_url = config.get("documentation") + try: + license_: License | None = license_by_id(config.get("license")) + except ValueError: + license_ = None + + package.license = license_ + package.keywords = config.get("keywords", []) + package.classifiers = config.get("classifiers", []) + + if "readme" in config: + if isinstance(config["readme"], str): + package.readmes = (root / config["readme"],) + else: + package.readmes = tuple(root / readme for readme in config["readme"]) + + if "dependencies" in config: + self._add_package_group_dependencies( + package=package, group=MAIN_GROUP, dependencies=config["dependencies"] + ) + + if with_groups and "group" in config: + for group_name, group_config in config["group"].items(): + group = DependencyGroup( + group_name, optional=group_config.get("optional", False) + ) + self._add_package_group_dependencies( + package=package, + group=group, + dependencies=group_config["dependencies"], + ) + + if with_groups and "dev-dependencies" in config: + self._add_package_group_dependencies( + package=package, group="dev", dependencies=config["dev-dependencies"] + ) + + extras = config.get("extras", {}) + for extra_name, requirements in extras.items(): + extra_name = canonicalize_name(extra_name) + package.extras[extra_name] = [] + + # Checking for dependency + for req in requirements: + req = Dependency(req, "*") + + for dep in package.requires: + if dep.name == req.name: + dep.in_extras.append(extra_name) + package.extras[extra_name].append(dep) + + if "build" in config: + build = config["build"] + if not isinstance(build, dict): + build = {"script": build} + package.build_config = build or {} + + if "include" in config: + package.include = [] + + for include in config["include"]: + if not isinstance(include, dict): + include = {"path": include} + + formats = include.get("format", []) + if formats and not isinstance(formats, list): + formats = [formats] + include["format"] = formats + + package.include.append(include) + + if "exclude" in config: + package.exclude = config["exclude"] + + if "packages" in config: + package.packages = config["packages"] + + # Custom urls + if "urls" in config: + package.custom_urls = config["urls"] + + package.scripts = config.get("scripts", {}) + package.entrypoints = config.get("plugins", {}) + + return package + + @classmethod + def _add_package_group_dependencies( + cls, + package: ProjectPackage, + group: str | DependencyGroup, + dependencies: DependencyConfig, + ) -> None: + from poetry.core.packages.dependency_group import MAIN_GROUP + + if isinstance(group, str): + if package.has_dependency_group(group): + group = package.dependency_group(group) + else: + from poetry.core.packages.dependency_group import DependencyGroup + + group = DependencyGroup(group) + + for name, constraints in dependencies.items(): + _constraints = ( + constraints if isinstance(constraints, list) else [constraints] + ) + for _constraint in _constraints: + if name.lower() == "python": + if group.name == MAIN_GROUP and isinstance(_constraint, str): + package.python_versions = _constraint + continue + + group.add_dependency( + cls.create_dependency( + name, + _constraint, + groups=[group.name], + root_dir=package.root_dir, + ) + ) + + package.add_dependency_group(group) + + @classmethod + def create_dependency( + cls, + name: str, + constraint: DependencyConstraint, + groups: list[str] | None = None, + root_dir: Path | None = None, + ) -> Dependency: + from pathlib import Path + + from poetry.core.constraints.generic import ( + parse_constraint as parse_generic_constraint, + ) + from poetry.core.constraints.version import parse_constraint + from poetry.core.packages.dependency import Dependency + from poetry.core.packages.dependency_group import MAIN_GROUP + from poetry.core.packages.directory_dependency import DirectoryDependency + from poetry.core.packages.file_dependency import FileDependency + from poetry.core.packages.url_dependency import URLDependency + from poetry.core.packages.utils.utils import create_nested_marker + from poetry.core.packages.vcs_dependency import VCSDependency + from poetry.core.version.markers import AnyMarker + from poetry.core.version.markers import parse_marker + + if groups is None: + groups = [MAIN_GROUP] + + if constraint is None: + constraint = "*" + + if isinstance(constraint, dict): + optional = constraint.get("optional", False) + python_versions = constraint.get("python") + platform = constraint.get("platform") + markers = constraint.get("markers") + + allows_prereleases = constraint.get("allow-prereleases", False) + + dependency: Dependency + if "git" in constraint: + # VCS dependency + dependency = VCSDependency( + name, + "git", + constraint["git"], + branch=constraint.get("branch", None), + tag=constraint.get("tag", None), + rev=constraint.get("rev", None), + directory=constraint.get("subdirectory", None), + groups=groups, + optional=optional, + develop=constraint.get("develop", False), + extras=constraint.get("extras", []), + ) + elif "file" in constraint: + file_path = Path(constraint["file"]) + + dependency = FileDependency( + name, + file_path, + directory=constraint.get("subdirectory", None), + groups=groups, + base=root_dir, + extras=constraint.get("extras", []), + ) + elif "path" in constraint: + path = Path(constraint["path"]) + + if root_dir: + is_file = root_dir.joinpath(path).is_file() + else: + is_file = path.is_file() + + if is_file: + dependency = FileDependency( + name, + path, + directory=constraint.get("subdirectory", None), + groups=groups, + optional=optional, + base=root_dir, + extras=constraint.get("extras", []), + ) + else: + subdirectory = constraint.get("subdirectory", None) + if subdirectory: + path = path / subdirectory + + dependency = DirectoryDependency( + name, + path, + groups=groups, + optional=optional, + base=root_dir, + develop=constraint.get("develop", False), + extras=constraint.get("extras", []), + ) + elif "url" in constraint: + dependency = URLDependency( + name, + constraint["url"], + directory=constraint.get("subdirectory", None), + groups=groups, + optional=optional, + extras=constraint.get("extras", []), + ) + else: + version = constraint["version"] + + dependency = Dependency( + name, + version, + optional=optional, + groups=groups, + allows_prereleases=allows_prereleases, + extras=constraint.get("extras", []), + ) + + marker = parse_marker(markers) if markers else AnyMarker() + + if python_versions: + marker = marker.intersect( + parse_marker( + create_nested_marker( + "python_version", parse_constraint(python_versions) + ) + ) + ) + + if platform: + marker = marker.intersect( + parse_marker( + create_nested_marker( + "sys_platform", parse_generic_constraint(platform) + ) + ) + ) + + if not marker.is_any(): + dependency.marker = marker + + dependency.source_name = constraint.get("source") + else: + dependency = Dependency(name, constraint, groups=groups) + + return dependency + + def metadata(self, name: str, default: Any = None) -> Any: + config = self._content["tool"]["poetry"] + + return config.get(name, default) + + @property + def hash_content(self) -> dict[str, Any]: + legacy_keys = ["dependencies", "source", "extras", "dev-dependencies"] + relevant_keys = [*legacy_keys, "group"] + + config = self._content["tool"]["poetry"] + + hash_content: dict[str, Any] = {} + + for key in relevant_keys: + data = config.get(key) + + if data is None and key not in legacy_keys: + continue + + hash_content[key] = data + + return hash_content + + @property + def poetry_config(self) -> dict[str, Any]: + """ + The custom poetry configuration + (i.e. the parts in [tool.poetry] that are not related to the package) + """ + relevant_keys: list[str] = ["packages", "include", "exclude", "source"] + + config = self._content["tool"]["poetry"] + poetry_config = {} + + for key in relevant_keys: + if key in config: + poetry_config[key] = config[key] + + return poetry_config diff --git a/src/poetry/core/pyproject/formats/standard_content_format.py b/src/poetry/core/pyproject/formats/standard_content_format.py new file mode 100644 index 000000000..d3ca46c3f --- /dev/null +++ b/src/poetry/core/pyproject/formats/standard_content_format.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from packaging.utils import canonicalize_name + +from poetry.core.json import validate_object +from poetry.core.packages.dependency_group import DependencyGroup +from poetry.core.packages.project_package import ProjectPackage +from poetry.core.pyproject.formats.content_format import ContentFormat +from poetry.core.pyproject.formats.validation_result import ValidationResult +from poetry.core.utils.helpers import combine_unicode + + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any + + from poetry.core.spdx.license import License + + +class StandardContentFormat(ContentFormat): + @classmethod + def supports(cls, content: dict[str, Any]) -> bool: + return "project" in content + + def validate(self, strict: bool = False) -> ValidationResult: + result = ValidationResult([], []) + + # Schema validation errors + validation_errors = validate_object(self._content, "poetry-pep621-schema") + + result.errors += validation_errors + + return result + + def to_package(self, root: Path, with_groups: bool = True) -> ProjectPackage: + from poetry.core.packages.dependency import Dependency + from poetry.core.packages.dependency_group import MAIN_GROUP + from poetry.core.spdx.helpers import license_by_id + + config = self._content["project"] + + package = ProjectPackage(config["name"], config["version"]) + package.root_dir = root + + if "requires-python" in config: + package.python_versions = config["requires-python"] + + for author in config.get("authors", []): + name, email = author.get("name"), author.get("email") + if name and email: + package.authors.append( + f"{combine_unicode(name)} <{combine_unicode(email)}>" + ) + elif name: + package.authors.append(combine_unicode(name)) + else: + package.authors.append(combine_unicode(name)) + + for maintainer in config.get("maintainers", []): + name, email = maintainer.get("name"), maintainer.get("email") + if name and email: + package.maintainers.append( + f"{combine_unicode(name)} <{combine_unicode(email)}>" + ) + elif name: + package.maintainers.append(combine_unicode(name)) + else: + package.maintainers.append(combine_unicode(name)) + + package.description = config.get("description") + + if "text" in config.get("license", {}): + try: + license_: License | None = license_by_id(config["license"]["text"]) + except ValueError: + license_ = None + else: + license_ = None + + package.license = license_ + package.keywords = config.get("keywords", []) + package.classifiers = config.get("classifiers", []) + + if "readme" in config: + readme = config["readme"] + if isinstance(readme, str): + package.readme = root / readme + elif "file" in readme: + package.readme = root / readme["file"] + package.readme_content_type = readme["content-type"] + elif "text" in readme: + package.readme_content = root / readme["text"] + package.readme_content_type = readme["content-type"] + + if "dependencies" in config: + self._add_package_group_dependencies( + package, MAIN_GROUP, config["dependencies"], root_dir=root + ) + + if "optional-dependencies" in config: + for extra_name, dependencies in config["optional-dependencies"].items(): + extra_name = canonicalize_name(extra_name) + package.extras[extra_name] = [] + + for dependency_constraint in dependencies: + dependency = Dependency.create_from_pep_508( + dependency_constraint, relative_to=root + ) + dependency._optional = True + dependency.in_extras.append(extra_name) + package.extras[extra_name].append(dependency) + + if not package.has_dependency_group(MAIN_GROUP): + group = DependencyGroup(MAIN_GROUP) + package.add_dependency_group(group) + else: + group = package.dependency_group(MAIN_GROUP) + + group.add_dependency(dependency) + + # Custom urls + if "urls" in config: + package.custom_urls = config["urls"] + + package.scripts = config.get("scripts", {}) + package.gui_scripts = config.get("gui-scripts", {}) + package.entrypoints = config.get("entry-points", {}) + + poetry_config = config.get("tool", {}).get("poetry", {}) + + if "build" in poetry_config: + build = poetry_config["build"] + if not isinstance(build, dict): + build = {"script": build} + package.build_config = build or {} + + if "include" in poetry_config: + package.include = [] + + for include in poetry_config["include"]: + if not isinstance(include, dict): + include = {"path": include} + + formats = include.get("format", []) + if formats and not isinstance(formats, list): + formats = [formats] + include["format"] = formats + + package.include.append(include) + + if "exclude" in poetry_config: + package.exclude = poetry_config["exclude"] + + if "packages" in poetry_config: + package.packages = poetry_config["packages"] + + return package + + def metadata(self, name: str, default: Any = None) -> Any: + config = self._content["project"] + + return config.get(name, default) + + @property + def hash_content(self) -> dict[str, Any]: + project_keys = ["dependencies", "optional-dependencies"] + poetry_keys = ["source", "group"] + + hash_content: dict[str, Any] = {} + + for key in project_keys: + data = self._content["project"].get(key) + + if data is None: + continue + + hash_content[f"project.{key}"] = data + + poetry_config = self.poetry_config + for key in poetry_keys: + data = poetry_config.get(key) + + if data is None: + continue + + hash_content[f"poetry.{key}"] = data + + return hash_content + + @property + def poetry_config(self) -> dict[str, Any]: + """ + The custom poetry configuration + (i.e. the parts in [tool.poetry] that are not related to the package) + """ + relevant_keys: list[str] = ["packages", "include", "exclude", "source"] + + config = self._content["tool"]["poetry"] + poetry_config = {} + + for key in relevant_keys: + if key in config: + poetry_config[key] = config[key] + + return poetry_config + + @classmethod + def _add_package_group_dependencies( + cls, + package: ProjectPackage, + group: str | DependencyGroup, + dependencies: list[str], + root_dir: Path | None = None, + ) -> None: + from poetry.core.packages.dependency import Dependency + + if isinstance(group, str): + if package.has_dependency_group(group): + group = package.dependency_group(group) + else: + from poetry.core.packages.dependency_group import DependencyGroup + + group = DependencyGroup(group) + + for constraint in dependencies: + dependency = Dependency.create_from_pep_508( + constraint, relative_to=root_dir + ) + group.add_dependency(dependency) + + package.add_dependency_group(group) diff --git a/src/poetry/core/pyproject/formats/validation_result.py b/src/poetry/core/pyproject/formats/validation_result.py new file mode 100644 index 000000000..e34de96ad --- /dev/null +++ b/src/poetry/core/pyproject/formats/validation_result.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ValidationResult: + errors: list[str] + warnings: list[str] diff --git a/src/poetry/core/pyproject/toml.py b/src/poetry/core/pyproject/toml.py index 45a2d9d87..57f6c3924 100644 --- a/src/poetry/core/pyproject/toml.py +++ b/src/poetry/core/pyproject/toml.py @@ -1,6 +1,5 @@ from __future__ import annotations -from contextlib import suppress from typing import TYPE_CHECKING from typing import Any @@ -11,11 +10,24 @@ if TYPE_CHECKING: from pathlib import Path + from poetry.core.pyproject.formats.content_format import ContentFormat + class PyProjectTOML: - def __init__(self, path: Path) -> None: + def __init__( + self, path: Path, content_format: type[ContentFormat] | None = None + ) -> None: + if content_format is None: + from poetry.core.pyproject.formats.legacy_content_format import ( + LegacyContentFormat, + ) + + content_format = LegacyContentFormat + self._path = path + self._content_format: type[ContentFormat] = content_format self._data: dict[str, Any] | None = None + self._content: ContentFormat | None = None self._build_system: BuildSystem | None = None @property @@ -33,6 +45,15 @@ def data(self) -> dict[str, Any]: return self._data + @property + def content(self) -> ContentFormat: + if self._content is not None: + return self._content + + self._content = self._content_format(self.data) + + return self._content + def is_build_system_defined(self) -> bool: return "build-system" in self.data @@ -42,7 +63,7 @@ def build_system(self) -> BuildSystem: build_backend = None requires = None - if not self.path.exists(): + if not self._path.exists(): build_backend = "poetry.core.masonry.api" requires = ["poetry-core"] @@ -56,25 +77,12 @@ def build_system(self) -> BuildSystem: @property def poetry_config(self) -> dict[str, Any]: - try: - tool = self.data["tool"] - assert isinstance(tool, dict) - config = tool["poetry"] - assert isinstance(config, dict) - return config - except KeyError as e: + if not self.is_poetry_project(): from poetry.core.pyproject.exceptions import PyProjectException - raise PyProjectException( - f"[tool.poetry] section not found in {self._path.as_posix()}" - ) from e + raise PyProjectException(f"{self._path} is not a Poetry pyproject file") - def is_poetry_project(self) -> bool: - from poetry.core.pyproject.exceptions import PyProjectException - - if self.path.exists(): - with suppress(PyProjectException): - _ = self.poetry_config - return True + return self.content.poetry_config - return False + def is_poetry_project(self) -> bool: + return not self.content.is_empty() diff --git a/tests/fixtures/sample_pep621_project/LICENSE b/tests/fixtures/sample_pep621_project/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/sample_pep621_project/README.md b/tests/fixtures/sample_pep621_project/README.md new file mode 100644 index 000000000..e2f7c1c06 --- /dev/null +++ b/tests/fixtures/sample_pep621_project/README.md @@ -0,0 +1 @@ +# My Package diff --git a/tests/fixtures/sample_pep621_project/pyproject.toml b/tests/fixtures/sample_pep621_project/pyproject.toml new file mode 100644 index 000000000..35633eafb --- /dev/null +++ b/tests/fixtures/sample_pep621_project/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "my-package" +version = "1.2.3" +description = "Some description." +readme = "README.md" +requires-python = ">=3.6" +license = { text = "MIT" } +keywords = ["packaging", "dependency", "poetry"] +authors = [ + { name = "Sébastien Eustace", email = "sebastien@eustace.io" } +] +maintainers = [ + { name = "Sébastien Eustace", email = "sebastien@eustace.io" } +] + +classifiers = [ + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules" +] + +# Requirements +dependencies = [ + "cleo ~=0.6", + "pendulum @ git+https://github.com/sdispater/pendulum.git@2.0", + "tomlkit @ git+https://github.com/sdispater/tomlkit.git@3bff550", + "pathlib2 ~=2.2 ; python_version == '2.7'", + # File dependency + "demo @ ../distributions/demo-0.1.0-py2.py3-none-any.whl", + # Dir dependency with setup.py + "my-package @ ../project_with_setup/", + # Dir dependency with pyproject.toml + "simple-project @ ../simple_project/", + # Dependency with markers + "functools32 ~=3.2.3 ; python_version ~= '2.7' and sys_platform == 'win32' or python_version in '3.4 3.5'", + # Dependency with python constraint + "dataclasses ~=0.7 ; python_full_version >= '3.6.1' and python_version < '3.7'" +] + +[project.optional-dependencies] +db = [ + "orator ~=0.9" +] +network = [ + "requests[security] ~=2.18" +] + +[project.urls] +homepage = "https://python-poetry.org" +repository = "https://github.com/python-poetry/poetry" +documentation = "https://python-poetry.org/docs" + +[project.scripts] +my-script = "my_package:main" + +[project.entry-points."blogtool.parsers"] +".rst" = "some_module::SomeClass" + +[tool.poetry.dependency-options] +tomlkit = { develop = true } + +[tool.poetry.group.dev] +dependencies = [ + "pytest ~=3.4" +] diff --git a/tests/fixtures/sample_project/pyproject.toml b/tests/fixtures/sample_project/pyproject.toml index 76712c62c..4fe467553 100644 --- a/tests/fixtures/sample_project/pyproject.toml +++ b/tests/fixtures/sample_project/pyproject.toml @@ -5,6 +5,9 @@ description = "Some description." authors = [ "Sébastien Eustace " ] +maintainers = [ + "Sébastien Eustace " +] license = "MIT" readme = "README.rst" @@ -26,7 +29,7 @@ python = "~2.7 || ^3.6" cleo = "^0.6" pendulum = { git = "https://github.com/sdispater/pendulum.git", branch = "2.0" } tomlkit = { git = "https://github.com/sdispater/tomlkit.git", rev = "3bff550", develop = false } -requests = { version = "^2.18", optional = true, extras=[ "security" ] } +requests = { version = "^2.18", optional = true, extras = ["security"] } pathlib2 = { version = "^2.2", python = "~2.7" } orator = { version = "^0.9", optional = true } @@ -44,11 +47,12 @@ simple-project = { path = "../simple_project/" } functools32 = { version = "^3.2.3", markers = "python_version ~= '2.7' and sys_platform == 'win32' or python_version in '3.4 3.5'" } # Dependency with python constraint -dataclasses = {version = "^0.7", python = ">=3.6.1,<3.7"} +dataclasses = { version = "^0.7", python = ">=3.6.1,<3.7" } [tool.poetry.extras] -db = [ "orator" ] +db = ["orator"] +network = ["requests"] # Non-regression test for https://github.com/python-poetry/poetry-core/pull/492. # The underlying issue occurred because `tomlkit` can either return a TOML table as `Table` instance or an diff --git a/tests/packages/test_package.py b/tests/packages/test_package.py index 4763c79a7..0e7f6f266 100644 --- a/tests/packages/test_package.py +++ b/tests/packages/test_package.py @@ -3,8 +3,6 @@ import random from pathlib import Path -from typing import TYPE_CHECKING -from typing import cast import pytest @@ -13,18 +11,15 @@ from poetry.core.factory import Factory from poetry.core.packages.dependency import Dependency from poetry.core.packages.dependency_group import DependencyGroup +from poetry.core.packages.directory_dependency import DirectoryDependency +from poetry.core.packages.file_dependency import FileDependency from poetry.core.packages.package import Package from poetry.core.packages.project_package import ProjectPackage +from poetry.core.packages.url_dependency import URLDependency +from poetry.core.packages.vcs_dependency import VCSDependency from poetry.core.version.exceptions import InvalidVersion -if TYPE_CHECKING: - from poetry.core.packages.directory_dependency import DirectoryDependency - from poetry.core.packages.file_dependency import FileDependency - from poetry.core.packages.url_dependency import URLDependency - from poetry.core.packages.vcs_dependency import VCSDependency - - @pytest.fixture() def package_with_groups() -> Package: package = Package("foo", "1.2.3") @@ -353,7 +348,8 @@ def test_to_dependency_for_directory() -> None: assert dep.constraint == package.version assert dep.features == frozenset({"bar", "baz"}) assert dep.is_directory() - dep = cast("DirectoryDependency", dep) + + assert isinstance(dep, DirectoryDependency) assert dep.path == path assert dep.source_type == "directory" assert dep.source_url == path.as_posix() @@ -377,7 +373,8 @@ def test_to_dependency_for_file() -> None: assert dep.constraint == package.version assert dep.features == frozenset({"bar", "baz"}) assert dep.is_file() - dep = cast("FileDependency", dep) + + assert isinstance(dep, FileDependency) assert dep.path == path assert dep.source_type == "file" assert dep.source_url == path.as_posix() @@ -399,7 +396,8 @@ def test_to_dependency_for_url() -> None: assert dep.constraint == package.version assert dep.features == frozenset({"bar", "baz"}) assert dep.is_url() - dep = cast("URLDependency", dep) + + assert isinstance(dep, URLDependency) assert dep.url == "https://example.com/path.tar.gz" assert dep.source_type == "url" assert dep.source_url == "https://example.com/path.tar.gz" @@ -423,7 +421,8 @@ def test_to_dependency_for_vcs() -> None: assert dep.constraint == package.version assert dep.features == frozenset({"bar", "baz"}) assert dep.is_vcs() - dep = cast("VCSDependency", dep) + + assert isinstance(dep, VCSDependency) assert dep.source_type == "git" assert dep.source == "https://github.com/foo/foo.git" assert dep.reference == "master" @@ -520,19 +519,15 @@ def test_get_readme_property_with_multiple_readme_files() -> None: package = Package("foo", "0.1.0") package.readmes = (Path("README.md"), Path("HISTORY.md")) - with pytest.deprecated_call(): - assert package.readme == Path("README.md") + assert package.readme == Path("README.md") def test_set_readme_property() -> None: package = Package("foo", "0.1.0") - with pytest.deprecated_call(): - package.readme = Path("README.md") + package.readme = Path("README.md") - assert package.readmes == (Path("README.md"),) - with pytest.deprecated_call(): - assert package.readme == Path("README.md") + assert package.readme == Path("README.md") @pytest.mark.parametrize( diff --git a/tests/pyproject/test_pyproject_toml.py b/tests/pyproject/test_pyproject_toml.py index 4105b5162..0622cc313 100644 --- a/tests/pyproject/test_pyproject_toml.py +++ b/tests/pyproject/test_pyproject_toml.py @@ -2,9 +2,6 @@ from pathlib import Path -import pytest - -from poetry.core.pyproject.exceptions import PyProjectException from poetry.core.pyproject.toml import PyProjectTOML from poetry.core.utils._compat import tomllib @@ -17,31 +14,6 @@ def test_pyproject_toml_simple( assert PyProjectTOML(pyproject_toml).data == data -def test_pyproject_toml_no_poetry_config(pyproject_toml: Path) -> None: - pyproject = PyProjectTOML(pyproject_toml) - - assert not pyproject.is_poetry_project() - - with pytest.raises(PyProjectException) as excval: - _ = pyproject.poetry_config - - assert f"[tool.poetry] section not found in {pyproject_toml.as_posix()}" in str( - excval.value - ) - - -def test_pyproject_toml_poetry_config( - pyproject_toml: Path, poetry_section: str -) -> None: - pyproject = PyProjectTOML(pyproject_toml) - with pyproject_toml.open("rb") as f: - doc = tomllib.load(f) - config = doc["tool"]["poetry"] - - assert pyproject.is_poetry_project() - assert pyproject.poetry_config == config - - def test_pyproject_toml_no_build_system_defaults() -> None: pyproject_toml = ( Path(__file__).parent.parent diff --git a/tests/test_core_version.py b/tests/test_core_version.py index 601ef49f5..2024b829e 100644 --- a/tests/test_core_version.py +++ b/tests/test_core_version.py @@ -8,4 +8,4 @@ def test_version_is_synced() -> None: pyproject = PyProjectTOML(Path(__file__).parent.parent.joinpath("pyproject.toml")) - assert __version__ == pyproject.poetry_config.get("version") + assert __version__ == pyproject.content.to_package(pyproject.path).version.text diff --git a/tests/test_factory.py b/tests/test_factory.py index 43f3c86aa..67b16dc27 100644 --- a/tests/test_factory.py +++ b/tests/test_factory.py @@ -24,7 +24,6 @@ from poetry.core.packages.file_dependency import FileDependency from poetry.core.packages.vcs_dependency import VCSDependency - fixtures_dir = Path(__file__).parent / "fixtures" @@ -60,8 +59,8 @@ def test_create_poetry() -> None: pendulum = dependencies["pendulum"] assert pendulum.pretty_constraint == "branch 2.0" - assert pendulum.is_vcs() pendulum = cast("VCSDependency", pendulum) + assert pendulum.is_vcs() assert pendulum.vcs == "git" assert pendulum.branch == "2.0" assert pendulum.source == "https://github.com/sdispater/pendulum.git" @@ -70,8 +69,8 @@ def test_create_poetry() -> None: tomlkit = dependencies["tomlkit"] assert tomlkit.pretty_constraint == "rev 3bff550" - assert tomlkit.is_vcs() tomlkit = cast("VCSDependency", tomlkit) + assert tomlkit.is_vcs() assert tomlkit.vcs == "git" assert tomlkit.rev == "3bff550" assert tomlkit.source == "https://github.com/sdispater/tomlkit.git" @@ -201,6 +200,130 @@ def test_create_poetry_with_dependencies_with_subdirectory() -> None: assert foo.directory == "sub" +def test_create_poetry_pep621() -> None: + poetry = Factory().create_poetry(fixtures_dir / "sample_pep621_project") + + package = poetry.package + + assert package.name == "my-package" + assert package.version.text == "1.2.3" + assert package.description == "Some description." + assert package.authors == ["Sébastien Eustace "] + assert package.license + assert package.license.id == "MIT" + assert package.readme is not None + assert ( + package.readme.relative_to(fixtures_dir).as_posix() + == "sample_pep621_project/README.md" + ) + assert package.urls["homepage"] == "https://python-poetry.org" + assert package.urls["repository"] == "https://github.com/python-poetry/poetry" + assert package.keywords == ["packaging", "dependency", "poetry"] + + assert package.python_versions == ">=3.6" + assert str(package.python_constraint) == ">=3.6" + + dependencies: dict[str, Dependency] = {} + for dep in package.requires: + dependencies[dep.name] = dep + + cleo = dependencies["cleo"] + assert cleo.pretty_constraint == ">=0.6,<1.0" + assert not cleo.is_optional() + + pendulum = dependencies["pendulum"] + assert pendulum.pretty_constraint == "rev 2.0" + assert pendulum.is_vcs() + pendulum = cast("VCSDependency", pendulum) + assert pendulum.vcs == "git" + assert pendulum.rev == "2.0" + assert pendulum.source == "https://github.com/sdispater/pendulum.git" + assert pendulum.allows_prereleases() + assert not pendulum.develop + + tomlkit = dependencies["tomlkit"] + assert tomlkit.pretty_constraint == "rev 3bff550" + assert tomlkit.is_vcs() + tomlkit = cast("VCSDependency", tomlkit) + assert tomlkit.vcs == "git" + assert tomlkit.rev == "3bff550" + assert tomlkit.source == "https://github.com/sdispater/tomlkit.git" + assert tomlkit.allows_prereleases() + assert not tomlkit.develop + + requests = dependencies["requests"] + assert requests.pretty_constraint == ">=2.18,<3.0" + assert not requests.is_vcs() + assert not requests.allows_prereleases() + assert requests.is_optional() + assert requests.extras == frozenset({"security"}) + + pathlib2 = dependencies["pathlib2"] + assert pathlib2.pretty_constraint == ">=2.2,<3.0" + assert pathlib2.python_versions == "~2.7" + assert not pathlib2.is_optional() + + demo = dependencies["demo"] + assert demo.is_file() + assert not demo.is_vcs() + assert demo.name == "demo" + assert demo.pretty_constraint == "*" + + demo = dependencies["my-package"] + assert not demo.is_file() + assert demo.is_directory() + assert not demo.is_vcs() + assert demo.name == "my-package" + assert demo.pretty_constraint == "*" + + simple_project = dependencies["simple-project"] + assert not simple_project.is_file() + assert simple_project.is_directory() + assert not simple_project.is_vcs() + assert simple_project.name == "simple-project" + assert simple_project.pretty_constraint == "*" + + functools32 = dependencies["functools32"] + assert functools32.name == "functools32" + assert functools32.pretty_constraint == ">=3.2.3,<3.3.0" + assert ( + str(functools32.marker) + == 'python_version ~= "2.7" and sys_platform == "win32" or python_version in' + ' "3.4 3.5"' + ) + + dataclasses = dependencies["dataclasses"] + assert dataclasses.name == "dataclasses" + assert dataclasses.pretty_constraint == ">=0.7,<1.0" + assert dataclasses.python_versions == ">=3.6.1 <3.7" + assert ( + str(dataclasses.marker) + == 'python_full_version >= "3.6.1" and python_version < "3.7"' + ) + + assert "db" in package.extras + + classifiers = package.classifiers + + assert classifiers == [ + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules", + ] + + assert package.all_classifiers == [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules", + ] + + def test_create_poetry_with_packages_and_includes() -> None: poetry = Factory().create_poetry( fixtures_dir.parent / "masonry" / "builders" / "fixtures" / "with-include"