Skip to content

Commit 2e87a32

Browse files
cpierceclaude
andcommitted
Add PyPI plugin for twine, flit, and hatch
Adds a shell plugin that provisions PyPI API tokens for Python packaging tools. One credential in 1Password powers all three tools, with each getting its native env vars (TWINE_PASSWORD, FLIT_PASSWORD, HATCH_INDEX_AUTH). Also imports existing credentials from .pypirc files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49810df commit 2e87a32

11 files changed

Lines changed: 447 additions & 0 deletions

File tree

plugins/pypi/api_token.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package pypi
2+
3+
import (
4+
"github.com/1Password/shell-plugins/sdk"
5+
"github.com/1Password/shell-plugins/sdk/importer"
6+
"github.com/1Password/shell-plugins/sdk/schema"
7+
"github.com/1Password/shell-plugins/sdk/schema/credname"
8+
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
9+
)
10+
11+
func APIToken() schema.CredentialType {
12+
return schema.CredentialType{
13+
Name: credname.APIToken,
14+
DocsURL: sdk.URL("https://pypi.org/help/#apitoken"),
15+
ManagementURL: sdk.URL("https://pypi.org/manage/account/#api-tokens"),
16+
Fields: []schema.CredentialField{
17+
{
18+
Name: fieldname.Token,
19+
MarkdownDescription: "API token used to authenticate to PyPI.",
20+
Secret: true,
21+
Composition: &schema.ValueComposition{
22+
Prefix: "pypi-",
23+
Charset: schema.Charset{
24+
Uppercase: true,
25+
Lowercase: true,
26+
Digits: true,
27+
Specific: []rune{'-', '_'},
28+
},
29+
},
30+
},
31+
},
32+
DefaultProvisioner: PyPIToolProvisioner("TWINE_USERNAME", "TWINE_PASSWORD"),
33+
Importer: importer.TryAll(
34+
importer.TryAllEnvVars(fieldname.Token, "TWINE_PASSWORD", "FLIT_PASSWORD", "HATCH_INDEX_AUTH"),
35+
TryPyPIRCFile(),
36+
),
37+
}
38+
}

plugins/pypi/api_token_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package pypi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/1Password/shell-plugins/sdk"
7+
"github.com/1Password/shell-plugins/sdk/plugintest"
8+
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
9+
)
10+
11+
func TestAPITokenImporter(t *testing.T) {
12+
plugintest.TestImporter(t, APIToken().Importer, map[string]plugintest.ImportCase{
13+
"TWINE_PASSWORD environment variable": {
14+
Environment: map[string]string{
15+
"TWINE_PASSWORD": "pypi-AgEIcHlwaS5vcmc",
16+
},
17+
ExpectedCandidates: []sdk.ImportCandidate{
18+
{
19+
Fields: map[sdk.FieldName]string{
20+
fieldname.Token: "pypi-AgEIcHlwaS5vcmc",
21+
},
22+
},
23+
},
24+
},
25+
"FLIT_PASSWORD environment variable": {
26+
Environment: map[string]string{
27+
"FLIT_PASSWORD": "pypi-flit123abc",
28+
},
29+
ExpectedCandidates: []sdk.ImportCandidate{
30+
{
31+
Fields: map[sdk.FieldName]string{
32+
fieldname.Token: "pypi-flit123abc",
33+
},
34+
},
35+
},
36+
},
37+
"HATCH_INDEX_AUTH environment variable": {
38+
Environment: map[string]string{
39+
"HATCH_INDEX_AUTH": "pypi-hatch789xyz",
40+
},
41+
ExpectedCandidates: []sdk.ImportCandidate{
42+
{
43+
Fields: map[sdk.FieldName]string{
44+
fieldname.Token: "pypi-hatch789xyz",
45+
},
46+
},
47+
},
48+
},
49+
".pypirc file with pypi section": {
50+
Files: map[string]string{
51+
"~/.pypirc": `[distutils]
52+
index-servers = pypi
53+
54+
[pypi]
55+
username = __token__
56+
password = pypi-secret123`,
57+
},
58+
ExpectedCandidates: []sdk.ImportCandidate{
59+
{
60+
Fields: map[sdk.FieldName]string{
61+
fieldname.Token: "pypi-secret123",
62+
},
63+
},
64+
},
65+
},
66+
".pypirc file with server-login section": {
67+
Files: map[string]string{
68+
"~/.pypirc": `[server-login]
69+
password = pypi-serverlogin456`,
70+
},
71+
ExpectedCandidates: []sdk.ImportCandidate{
72+
{
73+
Fields: map[sdk.FieldName]string{
74+
fieldname.Token: "pypi-serverlogin456",
75+
},
76+
},
77+
},
78+
},
79+
})
80+
}
81+
82+
func TestAPITokenProvisioner(t *testing.T) {
83+
plugintest.TestProvisioner(t, APIToken().DefaultProvisioner, map[string]plugintest.ProvisionCase{
84+
"default provisioner sets TWINE env vars": {
85+
ItemFields: map[sdk.FieldName]string{
86+
fieldname.Token: "pypi-AgEIcHlwaS5vcmc",
87+
},
88+
ExpectedOutput: sdk.ProvisionOutput{
89+
Environment: map[string]string{
90+
"TWINE_USERNAME": "__token__",
91+
"TWINE_PASSWORD": "pypi-AgEIcHlwaS5vcmc",
92+
},
93+
},
94+
},
95+
})
96+
}

plugins/pypi/flit.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package pypi
2+
3+
import (
4+
"github.com/1Password/shell-plugins/sdk"
5+
"github.com/1Password/shell-plugins/sdk/needsauth"
6+
"github.com/1Password/shell-plugins/sdk/schema"
7+
"github.com/1Password/shell-plugins/sdk/schema/credname"
8+
)
9+
10+
func FlitCLI() schema.Executable {
11+
return schema.Executable{
12+
Name: "Flit",
13+
Runs: []string{"flit"},
14+
DocsURL: sdk.URL("https://flit.pypa.io"),
15+
NeedsAuth: needsauth.IfAll(
16+
needsauth.NotForHelpOrVersion(),
17+
needsauth.ForCommand("publish"),
18+
),
19+
Uses: []schema.CredentialUsage{
20+
{
21+
Name: credname.APIToken,
22+
Provisioner: PyPIToolProvisioner("FLIT_USERNAME", "FLIT_PASSWORD"),
23+
},
24+
},
25+
}
26+
}

plugins/pypi/flit_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package pypi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/1Password/shell-plugins/sdk"
7+
"github.com/1Password/shell-plugins/sdk/plugintest"
8+
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
9+
)
10+
11+
func TestFlitCLIProvisioner(t *testing.T) {
12+
plugintest.TestProvisioner(t, FlitCLI().Uses[0].Provisioner, map[string]plugintest.ProvisionCase{
13+
"sets FLIT_USERNAME and FLIT_PASSWORD": {
14+
ItemFields: map[sdk.FieldName]string{
15+
fieldname.Token: "pypi-flit123abc",
16+
},
17+
ExpectedOutput: sdk.ProvisionOutput{
18+
Environment: map[string]string{
19+
"FLIT_USERNAME": "__token__",
20+
"FLIT_PASSWORD": "pypi-flit123abc",
21+
},
22+
},
23+
},
24+
})
25+
}
26+
27+
func TestFlitCLINeedsAuth(t *testing.T) {
28+
plugintest.TestNeedsAuth(t, FlitCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{
29+
"requires auth for publish": {
30+
Args: []string{"publish"},
31+
ExpectedNeedsAuth: true,
32+
},
33+
"skips auth for help": {
34+
Args: []string{"--help"},
35+
ExpectedNeedsAuth: false,
36+
},
37+
"skips auth for version": {
38+
Args: []string{"--version"},
39+
ExpectedNeedsAuth: false,
40+
},
41+
"skips auth for build command": {
42+
Args: []string{"build"},
43+
ExpectedNeedsAuth: false,
44+
},
45+
})
46+
}

plugins/pypi/hatch.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package pypi
2+
3+
import (
4+
"github.com/1Password/shell-plugins/sdk"
5+
"github.com/1Password/shell-plugins/sdk/needsauth"
6+
"github.com/1Password/shell-plugins/sdk/schema"
7+
"github.com/1Password/shell-plugins/sdk/schema/credname"
8+
)
9+
10+
func HatchCLI() schema.Executable {
11+
return schema.Executable{
12+
Name: "Hatch",
13+
Runs: []string{"hatch"},
14+
DocsURL: sdk.URL("https://hatch.pypa.io"),
15+
NeedsAuth: needsauth.IfAll(
16+
needsauth.NotForHelpOrVersion(),
17+
needsauth.ForCommand("publish"),
18+
),
19+
Uses: []schema.CredentialUsage{
20+
{
21+
Name: credname.APIToken,
22+
Provisioner: PyPIToolProvisioner("HATCH_INDEX_USER", "HATCH_INDEX_AUTH"),
23+
},
24+
},
25+
}
26+
}

plugins/pypi/hatch_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package pypi
2+
3+
import (
4+
"testing"
5+
6+
"github.com/1Password/shell-plugins/sdk"
7+
"github.com/1Password/shell-plugins/sdk/plugintest"
8+
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
9+
)
10+
11+
func TestHatchCLIProvisioner(t *testing.T) {
12+
plugintest.TestProvisioner(t, HatchCLI().Uses[0].Provisioner, map[string]plugintest.ProvisionCase{
13+
"sets HATCH_INDEX_USER and HATCH_INDEX_AUTH": {
14+
ItemFields: map[sdk.FieldName]string{
15+
fieldname.Token: "pypi-hatch789xyz",
16+
},
17+
ExpectedOutput: sdk.ProvisionOutput{
18+
Environment: map[string]string{
19+
"HATCH_INDEX_USER": "__token__",
20+
"HATCH_INDEX_AUTH": "pypi-hatch789xyz",
21+
},
22+
},
23+
},
24+
})
25+
}
26+
27+
func TestHatchCLINeedsAuth(t *testing.T) {
28+
plugintest.TestNeedsAuth(t, HatchCLI().NeedsAuth, map[string]plugintest.NeedsAuthCase{
29+
"requires auth for publish": {
30+
Args: []string{"publish"},
31+
ExpectedNeedsAuth: true,
32+
},
33+
"skips auth for help": {
34+
Args: []string{"--help"},
35+
ExpectedNeedsAuth: false,
36+
},
37+
"skips auth for version": {
38+
Args: []string{"--version"},
39+
ExpectedNeedsAuth: false,
40+
},
41+
"skips auth for build command": {
42+
Args: []string{"build"},
43+
ExpectedNeedsAuth: false,
44+
},
45+
})
46+
}

plugins/pypi/plugin.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package pypi
2+
3+
import (
4+
"github.com/1Password/shell-plugins/sdk"
5+
"github.com/1Password/shell-plugins/sdk/schema"
6+
)
7+
8+
func New() schema.Plugin {
9+
return schema.Plugin{
10+
Name: "pypi",
11+
Platform: schema.PlatformInfo{
12+
Name: "PyPI",
13+
Homepage: sdk.URL("https://pypi.org"),
14+
},
15+
Credentials: []schema.CredentialType{
16+
APIToken(),
17+
},
18+
Executables: []schema.Executable{
19+
TwineCLI(),
20+
FlitCLI(),
21+
HatchCLI(),
22+
},
23+
}
24+
}

plugins/pypi/provisioner.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package pypi
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/1Password/shell-plugins/sdk"
8+
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
9+
)
10+
11+
// pypiToolProvisioner sets username and password environment variables
12+
// for PyPI publishing tools. The username is always "__token__" when
13+
// using API token authentication.
14+
type pypiToolProvisioner struct {
15+
usernameEnvVar string
16+
passwordEnvVar string
17+
}
18+
19+
func PyPIToolProvisioner(usernameEnvVar, passwordEnvVar string) sdk.Provisioner {
20+
return pypiToolProvisioner{
21+
usernameEnvVar: usernameEnvVar,
22+
passwordEnvVar: passwordEnvVar,
23+
}
24+
}
25+
26+
func (p pypiToolProvisioner) Description() string {
27+
return fmt.Sprintf("Provision PyPI API token via %s and %s environment variables", p.usernameEnvVar, p.passwordEnvVar)
28+
}
29+
30+
func (p pypiToolProvisioner) Provision(ctx context.Context, in sdk.ProvisionInput, out *sdk.ProvisionOutput) {
31+
out.AddEnvVar(p.usernameEnvVar, "__token__")
32+
out.AddEnvVar(p.passwordEnvVar, in.ItemFields[fieldname.Token])
33+
}
34+
35+
func (p pypiToolProvisioner) Deprovision(ctx context.Context, in sdk.DeprovisionInput, out *sdk.DeprovisionOutput) {
36+
// Environment variables are automatically cleaned up when the process ends.
37+
}

plugins/pypi/pypirc_importer.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package pypi
2+
3+
import (
4+
"context"
5+
6+
"github.com/1Password/shell-plugins/sdk"
7+
"github.com/1Password/shell-plugins/sdk/importer"
8+
"github.com/1Password/shell-plugins/sdk/schema/fieldname"
9+
)
10+
11+
func TryPyPIRCFile() sdk.Importer {
12+
return importer.TryFile("~/.pypirc", func(ctx context.Context, contents importer.FileContents, in sdk.ImportInput, out *sdk.ImportAttempt) {
13+
cfg, err := contents.ToINI()
14+
if err != nil {
15+
return
16+
}
17+
18+
// Try [pypi] section first, then [server-login]
19+
for _, section := range []string{"pypi", "server-login"} {
20+
s := cfg.Section(section)
21+
if s == nil {
22+
continue
23+
}
24+
25+
password := s.Key("password").String()
26+
if password != "" {
27+
out.AddCandidate(sdk.ImportCandidate{
28+
Fields: map[sdk.FieldName]string{
29+
fieldname.Token: password,
30+
},
31+
})
32+
return
33+
}
34+
}
35+
})
36+
}

0 commit comments

Comments
 (0)