Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ require (
)

require (
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/BurntSushi/toml v1.3.2
github.com/Netflix/go-expect v0.0.0-20201125194554-85d881c3777e // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
Expand Down
53 changes: 53 additions & 0 deletions internal/python/wheel/distinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package wheel

import (
"bytes"
"encoding/csv"
"strconv"
)

// generator is recorded in WHEEL. It is intentionally version-free so the same
// source produces the same bytes across State Tool releases.
const generator = "state"

// record is one RECORD row: an archived file with its PEP 376 hash and size.
type record struct {
name string
hash string
size int64
}

// buildMetadata returns the dist-info METADATA contents.
func buildMetadata(meta resolvedMetadata) []byte {
var b bytes.Buffer
b.WriteString("Metadata-Version: 2.1\n")
b.WriteString("Name: " + meta.Name + "\n")
b.WriteString("Version: " + meta.Version + "\n")
if meta.Summary != "" {
b.WriteString("Summary: " + meta.Summary + "\n")
}
return b.Bytes()
}

// buildWheelFile returns the dist-info WHEEL contents for a pure-Python wheel.
func buildWheelFile() []byte {
var b bytes.Buffer
b.WriteString("Wheel-Version: 1.0\n")
b.WriteString("Generator: " + generator + "\n")
b.WriteString("Root-Is-Purelib: true\n")
b.WriteString("Tag: py3-none-any\n")
return b.Bytes()
}

// buildRecord returns the dist-info RECORD contents: one CSV row per archived
// file, plus RECORD's own row with an empty hash and size.
func buildRecord(records []record, recordName string) []byte {
var b bytes.Buffer
w := csv.NewWriter(&b)
for _, r := range records {
_ = w.Write([]string{r.name, r.hash, strconv.FormatInt(r.size, 10)})
}
_ = w.Write([]string{recordName, "", ""})
w.Flush()
return b.Bytes()
}
106 changes: 106 additions & 0 deletions internal/python/wheel/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package wheel

import (
"os"
"path/filepath"
"regexp"
"strings"

"github.com/ActiveState/cli/internal/errs"
"github.com/BurntSushi/toml"
)

// resolvedMetadata is the metadata after merging caller overrides with
// pyproject.toml, with name and version guaranteed non-empty.
type resolvedMetadata struct {
Name string
Version string
Summary string
}

// resolveMetadata fills empty fields of override from srcDir's pyproject.toml and
// returns the result, erroring if name or version is set by neither source.
func resolveMetadata(srcDir string, override Metadata) (resolvedMetadata, error) {
proj, err := readPyProject(filepath.Join(srcDir, "pyproject.toml"))
if err != nil {
return resolvedMetadata{}, err
}

res := resolvedMetadata{
Name: firstNonEmpty(override.Name, proj.Name),
Version: firstNonEmpty(override.Version, proj.Version),
Summary: firstNonEmpty(override.Summary, proj.Description),
}
if res.Name == "" || res.Version == "" {
return resolvedMetadata{}, ErrMissingMetadata
}
return res, nil
}

type pyProject struct {
Name string
Version string
Description string
}

// readPyProject reads the [project] table from a pyproject.toml. A missing file
// is not an error; caller-supplied metadata may stand in for it.
func readPyProject(path string) (pyProject, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return pyProject{}, nil
}
return pyProject{}, errs.Wrap(err, "could not read pyproject.toml")
}

var parsed struct {
Project struct {
Name string `toml:"name"`
Version string `toml:"version"`
Description string `toml:"description"`
} `toml:"project"`
}
if err := toml.Unmarshal(data, &parsed); err != nil {
return pyProject{}, errs.Wrap(err, "could not parse pyproject.toml")
}
return pyProject{
Name: parsed.Project.Name,
Version: parsed.Project.Version,
Description: parsed.Project.Description,
}, nil
}

func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}

var (
nameRunRe = regexp.MustCompile(`[-_.]+`)
versionRunRe = regexp.MustCompile(`[^A-Za-z0-9.]+`)
)

// normalizeName converts a distribution name to its wheel-filename form: runs of
// [-_.] collapse to a single underscore and the result is lowercased.
func normalizeName(name string) string {
return strings.ToLower(nameRunRe.ReplaceAllString(name, "_"))
}

// escapeVersion replaces runs of characters not allowed in a wheel-filename
// version component with a single underscore.
func escapeVersion(version string) string {
return versionRunRe.ReplaceAllString(version, "_")
}

func wheelFilename(name, version string) string {
return normalizeName(name) + "-" + escapeVersion(version) + "-py3-none-any.whl"
}

func distInfoDir(name, version string) string {
return normalizeName(name) + "-" + escapeVersion(version) + ".dist-info"
}
75 changes: 75 additions & 0 deletions internal/python/wheel/metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package wheel

import (
"errors"
"os"
"path/filepath"
"testing"
)

func writePyproject(t *testing.T, dir, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte(body), 0644); err != nil {
t.Fatal(err)
}
}

func TestResolveMetadata(t *testing.T) {
t.Run("pyproject fills empty fields", func(t *testing.T) {
dir := t.TempDir()
writePyproject(t, dir, "[project]\nname = \"proj\"\nversion = \"3.1\"\ndescription = \"from toml\"\n")
res, err := resolveMetadata(dir, Metadata{})
if err != nil {
t.Fatal(err)
}
if res.Name != "proj" || res.Version != "3.1" || res.Summary != "from toml" {
t.Errorf("got %+v", res)
}
})

t.Run("caller overrides pyproject", func(t *testing.T) {
dir := t.TempDir()
writePyproject(t, dir, "[project]\nname = \"proj\"\nversion = \"3.1\"\n")
res, err := resolveMetadata(dir, Metadata{Name: "override", Version: "9.9"})
if err != nil {
t.Fatal(err)
}
if res.Name != "override" || res.Version != "9.9" {
t.Errorf("override did not win: %+v", res)
}
})

t.Run("missing name and version errors", func(t *testing.T) {
dir := t.TempDir() // no pyproject.toml
if _, err := resolveMetadata(dir, Metadata{Name: "only-name"}); !errors.Is(err, ErrMissingMetadata) {
t.Errorf("error = %v, want ErrMissingMetadata", err)
}
})
}

func TestNormalizeName(t *testing.T) {
cases := map[string]string{
"My.Pkg-Name": "my_pkg_name",
"Flask": "flask",
"a--b__c.d": "a_b_c_d",
"already_ok": "already_ok",
}
for in, want := range cases {
if got := normalizeName(in); got != want {
t.Errorf("normalizeName(%q) = %q, want %q", in, got, want)
}
}
}

func TestEscapeVersion(t *testing.T) {
cases := map[string]string{
"1.0": "1.0",
"1.0+local": "1.0_local",
"2.0-rc1": "2.0_rc1",
}
for in, want := range cases {
if got := escapeVersion(in); got != want {
t.Errorf("escapeVersion(%q) = %q, want %q", in, got, want)
}
}
}
Loading
Loading