Skip to content

Commit db45aef

Browse files
authored
Support detecting python interpreter requirements from standard files (#2617)
1 parent e73df36 commit db45aef

35 files changed

+3836
-13
lines changed

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ require (
1717
golang.org/x/net v0.37.0
1818
)
1919

20-
require golang.org/x/crypto v0.36.0 // indirect
20+
require (
21+
golang.org/x/crypto v0.36.0 // indirect
22+
gopkg.in/ini.v1 v1.67.0 // indirect
23+
)
2124

2225
require (
2326
github.com/alessio/shellescape v1.4.2 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
103103
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
104104
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
105105
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
106+
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
107+
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
106108
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
107109
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
108110
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/bundles/manifest.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,14 @@ type Metadata struct {
5353
HasParameters bool `json:"has_parameters,omitempty"` // True if this is content allows parameter customization.
5454
}
5555

56+
type EnvironmentPython struct {
57+
PythonRequires string `json:"requires"` // The Python version to use for the content environment
58+
}
59+
5660
type Environment struct {
57-
Image string `json:"image"` // The image to use during content build/execution
58-
Prebuilt bool `json:"prebuilt"` // Determines whether Connect should skip the build phase for this content.
61+
Image string `json:"image"` // The image to use during content build/execution
62+
Prebuilt bool `json:"prebuilt"` // Determines whether Connect should skip the build phase for this content.
63+
Python *EnvironmentPython `json:"python,omitempty"` // If non-null, specifies the Python environment
5964
}
6065

6166
type Python struct {
@@ -175,6 +180,15 @@ func NewManifestFromConfig(cfg *config.Config) *Manifest {
175180
PackageFile: cfg.Python.PackageFile,
176181
},
177182
}
183+
// If the configuration specifies a specific python version constraint
184+
// (e.g. ">=3.8"), declare the environment requires that version.
185+
if cfg.Python.RequiresPythonVersion != "" {
186+
m.Environment = &Environment{
187+
Python: &EnvironmentPython{
188+
PythonRequires: cfg.Python.RequiresPythonVersion,
189+
},
190+
}
191+
}
178192
}
179193
if cfg.Jupyter != nil {
180194
m.Jupyter = &Jupyter{
@@ -214,7 +228,12 @@ func (manifest *Manifest) AddFile(path string, fileMD5 []byte) {
214228
}
215229

216230
func (manifest *Manifest) ToJSON() ([]byte, error) {
217-
return json.MarshalIndent(manifest, "", "\t")
231+
buf := &bytes.Buffer{}
232+
enc := json.NewEncoder(buf)
233+
enc.SetEscapeHTML(false)
234+
enc.SetIndent("", "\t")
235+
err := enc.Encode(manifest)
236+
return buf.Bytes(), err
218237
}
219238

220239
func (manifest *Manifest) Clone() (*Manifest, error) {

internal/config/types.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,10 @@ func (c *Config) HasSecret(secret string) bool {
125125
type Environment = map[string]string
126126

127127
type Python struct {
128-
Version string `toml:"version,omitempty" json:"version"`
129-
PackageFile string `toml:"package_file,omitempty" json:"packageFile"`
130-
PackageManager string `toml:"package_manager,omitempty" json:"packageManager"`
128+
Version string `toml:"version,omitempty" json:"version"`
129+
PackageFile string `toml:"package_file,omitempty" json:"packageFile"`
130+
PackageManager string `toml:"package_manager,omitempty" json:"packageManager"`
131+
RequiresPythonVersion string `toml:"requires_python,omitempty" json:"requiresPython"`
131132
}
132133

133134
func (p *Python) FillDefaults(
@@ -150,6 +151,9 @@ func (p *Python) FillDefaults(
150151
if p.PackageManager == "" {
151152
p.PackageManager = python.GetPackageManager()
152153
}
154+
if p.RequiresPythonVersion == "" {
155+
p.RequiresPythonVersion = python.GetPythonRequires()
156+
}
153157
}
154158
}
155159

internal/config/types_defaults_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ func (s *ConfigFillDefaultsSuite) createMockPythonInterpreter() interpreters.Pyt
5555
iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("/bin/python", s.cwd.Fs()), nil)
5656
iMock.On("GetPythonVersion").Return("1.2.3", nil)
5757
iMock.On("GetPackageManager").Return("pip")
58+
iMock.On("GetPythonRequires").Return("")
5859
iMock.On("GetLockFilePath").Return("requirements.txt", true, nil)
5960
return iMock
6061
}
@@ -66,6 +67,7 @@ func (s *ConfigFillDefaultsSuite) createMockPythonMissingInterpreter() interpret
6667
iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError)
6768
iMock.On("GetPythonVersion").Return("", missingError)
6869
iMock.On("GetPackageManager").Return("pip")
70+
iMock.On("GetPythonRequires").Return("")
6971
iMock.On("GetLockFilePath").Return("", false, missingError)
7072
return iMock
7173
}

internal/inspect/python.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,18 @@ func (i *defaultPythonInspector) InspectPython() (*config.Python, error) {
9797
i.log.Warn("can't find requirements.txt")
9898
}
9999

100+
pyProjectRequires := interpreters.NewPyProjectPythonRequires(i.base)
101+
python_requires, err := pyProjectRequires.GetPythonVersionRequirement()
102+
if err != nil {
103+
i.log.Warn("Error retrieving Python requires", err)
104+
python_requires = ""
105+
}
106+
100107
return &config.Python{
101-
Version: version,
102-
PackageFile: reqFile.String(),
103-
PackageManager: "pip",
108+
Version: version,
109+
PackageFile: reqFile.String(),
110+
PackageManager: "pip",
111+
RequiresPythonVersion: python_requires,
104112
}, nil
105113
}
106114

internal/interpreters/python.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type PythonInterpreter interface {
2525
GetPythonVersion() (string, error)
2626
GetPackageManager() string
2727
GetPreferredPath() string
28+
GetPythonRequires() string
2829
}
2930

3031
type defaultPythonInterpreter struct {
@@ -257,3 +258,13 @@ func (i *defaultPythonInterpreter) GetLockFilePath() (util.RelativePath, bool, e
257258
exists, err := i.existsFunc(lockFileAbsPath.Path)
258259
return util.NewRelativePath(lockFile, i.fs), exists, err
259260
}
261+
262+
func (i *defaultPythonInterpreter) GetPythonRequires() string {
263+
pyProjectRequires := NewPyProjectPythonRequires(i.base)
264+
python_requires, err := pyProjectRequires.GetPythonVersionRequirement()
265+
if err != nil {
266+
i.log.Warn("Error retrieving Python requires", err)
267+
python_requires = ""
268+
}
269+
return python_requires
270+
}

internal/interpreters/python_mock.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,9 @@ func (m *MockPythonInterpreter) GetPreferredPath() string {
9090
arg0 := args.Get(0)
9191
return arg0.(string)
9292
}
93+
94+
func (m *MockPythonInterpreter) GetPythonRequires() string {
95+
args := m.Called()
96+
arg0 := args.Get(0)
97+
return arg0.(string)
98+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package interpreters
2+
3+
// Copyright (C) 2025 by Posit Software, PBC.
4+
5+
import (
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/posit-dev/publisher/internal/util"
11+
12+
"gopkg.in/ini.v1"
13+
14+
toml "github.com/pelletier/go-toml/v2"
15+
)
16+
17+
type PyProjectPythonRequires struct {
18+
ProjectPath util.AbsolutePath
19+
}
20+
21+
func NewPyProjectPythonRequires(projectPath util.AbsolutePath) *PyProjectPythonRequires {
22+
return &PyProjectPythonRequires{
23+
ProjectPath: projectPath,
24+
}
25+
}
26+
27+
// Find the python version requested by the project if specified in any of the
28+
// supported metadata files. The order of precedence is:
29+
// 1. .python-version
30+
// 2. pyproject.toml
31+
// 3. setup.cfg
32+
//
33+
// The version specifications are the one defined by PEP 440
34+
// if no version is found, an error is returned.
35+
func (p *PyProjectPythonRequires) GetPythonVersionRequirement() (string, error) {
36+
if version, err := p.readPythonVersionFile(); err == nil && version != "" {
37+
return version, nil
38+
}
39+
if version, err := p.readPyProjectToml(); err == nil && version != "" {
40+
return version, nil
41+
}
42+
if version, err := p.readSetupCfg(); err == nil && version != "" {
43+
return version, nil
44+
}
45+
return "", errors.New("no python version requirement found")
46+
}
47+
48+
// Read a .python-version file and return the version string.
49+
// the file is a plain text file that contains only the version specification.
50+
func (p *PyProjectPythonRequires) readPythonVersionFile() (string, error) {
51+
path := p.ProjectPath.Join(".python-version")
52+
data, err := path.ReadFile()
53+
if err != nil {
54+
return "", err
55+
}
56+
return strings.TrimSpace(string(data)), nil
57+
}
58+
59+
// Read a pyproject.toml file and return the version string.
60+
// The file is a TOML file that contains a [project] section
61+
// with a requires-python key.
62+
//
63+
// [project]
64+
// requires-python = ">=3.8"
65+
func (p *PyProjectPythonRequires) readPyProjectToml() (string, error) {
66+
path := p.ProjectPath.Join("pyproject.toml")
67+
data, err := path.ReadFile()
68+
if err != nil {
69+
return "", err
70+
}
71+
72+
var tomlData map[string]any
73+
if err := toml.Unmarshal(data, &tomlData); err != nil {
74+
return "", err
75+
}
76+
77+
// project.requires-python
78+
if project, ok := tomlData["project"].(map[string]any); ok {
79+
if req, ok := project["requires-python"]; ok {
80+
return fmt.Sprintf("%v", req), nil
81+
}
82+
}
83+
84+
return "", nil
85+
}
86+
87+
// Read a setup.cfg file and return the version string.
88+
// The file is an INI file that contains an [options] section
89+
// with a python_requires key.
90+
//
91+
// [options]
92+
// python_requires = ">=3.8"
93+
func (p *PyProjectPythonRequires) readSetupCfg() (string, error) {
94+
path := p.ProjectPath.Join("setup.cfg")
95+
if exists, _ := path.Exists(); !exists {
96+
return "", errors.New("setup.cfg file does not exist")
97+
}
98+
99+
cfg, err := ini.Load(path.String())
100+
if err != nil {
101+
return "", err
102+
}
103+
104+
// options.python_requires
105+
if section, err := cfg.GetSection("options"); err == nil {
106+
if key, err := section.GetKey("python_requires"); err == nil {
107+
return key.String(), nil
108+
}
109+
}
110+
111+
return "", nil
112+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package interpreters
2+
3+
import (
4+
"testing"
5+
6+
"github.com/posit-dev/publisher/internal/util"
7+
"github.com/posit-dev/publisher/internal/util/utiltest"
8+
"github.com/spf13/afero"
9+
"github.com/stretchr/testify/suite"
10+
)
11+
12+
// Copyright (C) 2025 by Posit Software, PBC.
13+
14+
type PythonRequiresSuite struct {
15+
utiltest.Suite
16+
base util.AbsolutePath
17+
cwd util.AbsolutePath
18+
fs afero.Fs
19+
}
20+
21+
func TestPythonRequiresSuite(t *testing.T) {
22+
suite.Run(t, new(PythonRequiresSuite))
23+
}
24+
25+
func (s *PythonRequiresSuite) SetupTest() {
26+
cwd, err := util.Getwd(s.fs)
27+
s.NoError(err)
28+
s.cwd = cwd
29+
30+
s.fs = afero.NewOsFs()
31+
32+
s.base = s.cwd.Join("..", "..", "test", "sample-content").WithFs(s.fs)
33+
}
34+
35+
func (s *PythonRequiresSuite) TestGetPythonRequiresPyProject() {
36+
fastapi_path := s.base.Join("fastapi-simple")
37+
pyRequires := NewPyProjectPythonRequires(fastapi_path)
38+
39+
pythonRequires, err := pyRequires.GetPythonVersionRequirement()
40+
s.NoError(err)
41+
s.NotEmpty(pythonRequires)
42+
s.Equal(">=3.8", pythonRequires)
43+
}
44+
45+
func (s *PythonRequiresSuite) TestGetPythonRequiresSetupCfg() {
46+
gradio_path := s.base.Join("gradio")
47+
pyRequires := NewPyProjectPythonRequires(gradio_path)
48+
49+
pythonRequires, err := pyRequires.GetPythonVersionRequirement()
50+
s.NoError(err)
51+
s.NotEmpty(pythonRequires)
52+
s.Equal(">=3.9", pythonRequires)
53+
}
54+
55+
func (s *PythonRequiresSuite) TestGetPythonRequiresPythonVersion() {
56+
gradio_path := s.base.Join("shinyapp")
57+
pyRequires := NewPyProjectPythonRequires(gradio_path)
58+
59+
pythonRequires, err := pyRequires.GetPythonVersionRequirement()
60+
s.NoError(err)
61+
s.NotEmpty(pythonRequires)
62+
s.Equal(">=3.8, <3.12", pythonRequires)
63+
}

internal/schema/schemas/draft/posit-publishing-schema-v3.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@
116116
"default": "pip",
117117
"description": "Package manager that will install the dependencies. If package-manager is none, dependencies are assumed to be pre-installed on the server. The default is 'pip'.",
118118
"examples": ["pip", "conda", "pipenv", "poetry", "none"]
119+
},
120+
"requires_python": {
121+
"type": "string",
122+
"default": "",
123+
"description": "Python interpreter version, in PEP 440 format, required to run the content. If not specified it will be detected from the one in use.",
124+
"examples": [">3.8", "<3.9", "==3.5"]
119125
}
120126
}
121127
},

internal/schema/schemas/posit-publishing-schema-v3.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@
105105
"default": "pip",
106106
"description": "Package manager that will install the dependencies. If package-manager is none, dependencies will not be installed.",
107107
"examples": ["pip", "none"]
108+
},
109+
"requires_python": {
110+
"type": "string",
111+
"default": "",
112+
"description": "Python interpreter version, in PEP 440 format, required to run the content in. If not specified it will be detected from the one in use.",
113+
"examples": [">3.8", "<3.9", "=3.5"]
108114
}
109115
}
110116
},

internal/services/api/get_interpreters_test.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ func (s *GetInterpretersSuite) createMockPythonInterpreter() interpreters.Python
7575
iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("/bin/python", s.cwd.Fs()), nil)
7676
iMock.On("GetPythonVersion").Return("1.2.3", nil)
7777
iMock.On("GetPackageManager").Return("pip")
78+
iMock.On("GetPythonRequires").Return("")
7879
iMock.On("GetLockFilePath").Return("requirements.txt", true, nil)
7980
iMock.On("GetPreferredPath").Return("bin/my_python")
8081
return iMock
@@ -87,6 +88,7 @@ func (s *GetInterpretersSuite) createMockPythonMissingInterpreter() interpreters
8788
iMock.On("GetPythonExecutable").Return(util.NewAbsolutePath("", s.cwd.Fs()), missingError)
8889
iMock.On("GetPythonVersion").Return("", missingError)
8990
iMock.On("GetPackageManager").Return("pip")
91+
iMock.On("GetPythonRequires").Return("")
9092
iMock.On("GetLockFilePath").Return("", false, missingError)
9193
iMock.On("GetPreferredPath").Return("bin/my_python")
9294
return iMock
@@ -142,9 +144,10 @@ func (s *GetInterpretersSuite) TestGetInterpretersWhenPassedIn() {
142144
s.NoError(dec.Decode(&res))
143145

144146
expectedPython := &config.Python{
145-
Version: "1.2.3",
146-
PackageFile: "requirements.txt",
147-
PackageManager: "pip",
147+
Version: "1.2.3",
148+
PackageFile: "requirements.txt",
149+
PackageManager: "pip",
150+
RequiresPythonVersion: "",
148151
}
149152
expectedR := &config.R{
150153
Version: "3.4.5",

0 commit comments

Comments
 (0)