Skip to content

Commit

Permalink
Add incompatibile versions scenarios (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
zanieb committed Dec 14, 2023
1 parent 1c403f2 commit 21a1f94
Show file tree
Hide file tree
Showing 8 changed files with 1,165 additions and 81 deletions.
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,23 @@ packse build scenario/example.json
The `build/` directory will contain sources for all of the packages in the scenario.
The `dist/` directory will contain built distributions for all of the packages in the scenario.


When a scenario is built, it is given a unique identifier based on a hash of the scenario contents and the project
templates used to generate the packages. Each package in the scenario will include the unique identifier.

The `build` command will print the unique identifier for the scenario e.g. `example-cd797223`. A special entrypoint
package is generated for the scenario which can be used later to install the scenario.

The `PACKSE_VERSION_SEED` environment variable can be used to override the seed used to generate the unique
identifier, allowing versions to differ based on information outside of packse.

### Viewing scenarios

**Not yet implemented**

The dependency tree of a scenario can be previewed using the `view` command:

```
$ packse view scenarios/example.json
example-9e723676
example-cd797223
└── a-1.0.0
└── requires b>=1.0.0
└── satisfied by b-1.0.0
Expand All @@ -56,22 +58,22 @@ example-9e723676

### Publishing scenarios

Built scenarios can be published to a Python Package Index.

For example, to upload to the test PyPI:
Built scenarios can be published to a Python Package Index with the `publish` command:

```bash
twine upload -r testpypi dist/<scenario>/*
packse publish dist/example-cd797223
```

By default, packages are published to the Test PyPI server.

### Testing scenarios

Published scenarios can then be tested with your choice of package manager.

For example, with `pip`:

```bash
pip install -i https://test.pypi.org/simple/ <scenario>-<package>==1.0.0
pip install -i https://test.pypi.org/simple/ example-cd797223
```

### Writing new scenarios
Expand Down
99 changes: 99 additions & 0 deletions scenarios/requires-incompatible-versions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
[
{
"name": "requires-direct-incompatible-versions",
"description": "Package `a` requires two incompatible versions of package `b`",
"root": "a",
"packages": {
"a": {
"versions": {
"1.0.0": {
"requires_python": ">=3.7",
"requires": [
"b==1.0.0",
"b==2.0.0"
]
}
}
},
"b": {
"versions": {
"1.0.0": {},
"2.0.0": {}
}
}
}
},
{
"name": "requires-transitive-incompatible-with-root-version",
"description": "Package `a` requires package `b` and both `a` and `b` require different versions of `c`",
"root": "a",
"packages": {
"a": {
"versions": {
"1.0.0": {
"requires": [
"b",
"c==1.0.0"
]
}
}
},
"b": {
"versions": {
"1.0.0": {
"requires": [
"c==2.0.0"
]
}
}
},
"c": {
"versions": {
"1.0.0": {},
"2.0.0": {}
}
}
}
},
{
"name": "requires-transitive-incompatible-with-transitive",
"description": "Package `a` requires package `b` and `c`; `b` and `c` require different versions of `d`",
"root": "a",
"packages": {
"a": {
"versions": {
"1.0.0": {
"requires": [
"b",
"c"
]
}
}
},
"b": {
"versions": {
"1.0.0": {
"requires": [
"d==1.0.0"
]
}
}
},
"c": {
"versions": {
"1.0.0": {
"requires": [
"d==2.0.0"
]
}
}
},
"d": {
"versions": {
"1.0.0": {},
"2.0.0": {}
}
}
}
}
]
78 changes: 59 additions & 19 deletions src/packse/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
InvalidScenario,
ScenarioNotFound,
)
from packse.scenario import Package, Scenario, load_scenarios, scenario_prefix
from packse.scenario import (
Package,
PackageVersion,
Scenario,
load_scenarios,
scenario_prefix,
)
from packse.template import create_from_template

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -44,7 +50,7 @@ def build_scenario(scenario: Scenario, rm_destination: bool) -> str:
"""
Build the scenario defined at the given path.
Returns the scenario's root package name.
Returns the scenario's entrypoint package name.
"""
prefix = scenario_prefix(scenario)

Expand Down Expand Up @@ -85,7 +91,34 @@ def build_scenario(scenario: Scenario, rm_destination: bool) -> str:
dist_destination=dist_destination,
)

return f"{prefix}-{scenario.root}"
build_scenario_package(
scenario=scenario,
prefix=prefix,
name="",
package=make_entrypoint_package(scenario),
work_dir=work_dir,
build_destination=build_destination,
dist_destination=dist_destination,
)

return prefix


def make_entrypoint_package(scenario: Scenario) -> Package:
"""
Generate an entrypoint `Package` for a scenario that just requires the scenario root package.
"""
return Package(
versions={
"0.0.0": PackageVersion(
requires=[scenario.root],
# Do not build wheels for the root package
wheel=False,
# The scenario's description is used for the entrypoint package
description=scenario.description,
)
}
)


def build_scenario_package(
Expand All @@ -97,12 +130,16 @@ def build_scenario_package(
build_destination: Path,
dist_destination: Path,
):
package_name = f"{prefix}-{name}"
# Only allow the name to be empty for entrypoint packages
assert name or list(package.versions.keys()) == ["0.0.0"]

package_name = f"{prefix}-{name}" if name else prefix

# Generate a Python module name
module_name = package_name.replace("-", "_")

for version, specification in package.versions.items():
for version, package_version in package.versions.items():
logger.info("Generating project for '%s'", package_name)
package_destination = create_from_template(
build_destination,
template_name=scenario.template,
Expand All @@ -111,32 +148,35 @@ def build_scenario_package(
"package-name": package_name,
"module-name": module_name,
"version": version,
"dependencies": [f"{prefix}-{spec}" for spec in specification.requires],
"requires-python": specification.requires_python,
"dependencies": [
f"{prefix}-{spec}" for spec in package_version.requires
],
"requires-python": package_version.requires_python,
"description": package_version.description,
},
)

logger.info(
"Building %s with hatch",
package_destination.relative_to(work_dir),
)

for dist in build_package_distributions(package_destination):
for dist in build_package_distributions(package_version, package_destination):
shared_path = dist_destination / dist.name
logger.info("Linked distribution to %s", shared_path.relative_to(work_dir))
shared_path.hardlink_to(dist)


def build_package_distributions(target: Path) -> Generator[Path, None, None]:
def build_package_distributions(
package_version: PackageVersion, target: Path
) -> Generator[Path, None, None]:
"""
Build package distributions, yield each built distribution path, then delete the distribution folder.
"""

command = ["hatch", "build"]
if package_version.sdist:
command.extend(["-t", "sdist"])
if package_version.wheel:
command.extend(["-t", "wheel"])

try:
output = subprocess.check_output(
["hatch", "build"],
cwd=target,
stderr=subprocess.STDOUT,
)
output = subprocess.check_output(command, cwd=target, stderr=subprocess.STDOUT)

yield from sorted((target / "dist").iterdir())
shutil.rmtree(target / "dist")
Expand Down
11 changes: 8 additions & 3 deletions src/packse/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
class PackageVersion(msgspec.Struct):
requires_python: str | None = ">=3.7"
requires: list[str] = []
sdist: bool = True
wheel: bool = True
description: str = ""

def hash(self) -> str:
"""
Expand Down Expand Up @@ -39,7 +42,7 @@ def hash(self) -> str:
class Scenario(msgspec.Struct):
name: str
"""
The name of the scenario
The name of the scenario.
"""

packages: dict[str, Package]
Expand All @@ -49,7 +52,9 @@ class Scenario(msgspec.Struct):

root: str
"""
The root package, intended to be installed to test the scenario.
The root package of the scenario.
The scenario entrypoint package will require this package.
"""

template: str = "simple"
Expand All @@ -59,7 +64,7 @@ class Scenario(msgspec.Struct):

description: str | None = None
"""
The description of the scenario
The description of the scenario.
"""

def hash(self) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ name = "{{ package-name }}"
version = "{{ version }}"
dependencies = {{ dependencies }}
requires-python = "{{ requires-python }}"
description = "{{ description }}"
Loading

0 comments on commit 21a1f94

Please sign in to comment.