Skip to content

Commit 8a25e65

Browse files
committed
Introduce new class: Pipenv
This class is designed to facilitate running a Pipenv command against multiple versions of Python, which is something that Pipenv cannot do by itself very gracefully. We do this by using the `--python` argument to Pipenv, which assumes that the `Pipfile` does not contain a `requirements` section which may cause Pipenv to throw an error.
1 parent 95f1f8c commit 8a25e65

File tree

3 files changed

+350
-0
lines changed

3 files changed

+350
-0
lines changed

src/com/ableton/Pipenv.groovy

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.ableton
2+
3+
4+
/**
5+
* Provides an easy way to run a command with Pipenv using multiple Python versions.
6+
*/
7+
class Pipenv implements Serializable {
8+
/**
9+
* Script context.
10+
* <strong>Required value, may not be null!</strong>
11+
*/
12+
@SuppressWarnings('FieldTypeRequired')
13+
def script
14+
15+
/**
16+
* Run a command with Pipenv using multiple versions of Python. Because the virtualenv
17+
* created by Pipenv must be wiped out between runs, this function cannot be
18+
* parallelized and therefore the commands are run serially for each Python version.
19+
*
20+
* @param args Map of arguments. Valid arguments include:
21+
* <ul>
22+
* <li>
23+
* {@code command}: Command to run.
24+
* <strong>Required value, may not be null!</strong>
25+
* </li>
26+
* <li>
27+
* {@code pythonVersions}: List of Python versions to run the command
28+
* with. This argument is passed to Pipenv via {@code pipenv --python}.
29+
* See {@code pipenv --help} for supported syntax.
30+
* <strong>Required value, may not be null!</strong>
31+
* </li>
32+
* <li>
33+
* {@code returnStatus}: Return the status code of the script.
34+
* <strong>Note:</strong> this option and the {@code returnStdout}
35+
* option are mutually exclusive.
36+
* </li>
37+
* <li>
38+
* {@code returnStatus}: Return standard output from the script.
39+
* <strong>Note:</strong> this option and the {@code returnStatus}
40+
* option are mutually exclusive.
41+
* </li>
42+
* </ul>
43+
* @return Map of return values. The keys in the map correspond to the Python versions
44+
* given in {@code args.pythonVersions}, and the values are either integers,
45+
* strings, or null, depending on whether {@code returnStatus},
46+
* {@code returnStdout}, or neither option was specified (respectively).
47+
*/
48+
Map runCommand(Map args) {
49+
assert script
50+
assert args
51+
assert args.command
52+
assert args.pythonVersions
53+
54+
Map result = [:]
55+
56+
try {
57+
args.pythonVersions.each { python ->
58+
script.sh "pipenv install --dev --python ${python}"
59+
result[python] = script.sh(
60+
returnStatus: args.returnStatus ?: false,
61+
returnStdout: args.returnStdout ?: false,
62+
script: "pipenv run ${args.command}",
63+
)
64+
}
65+
} finally {
66+
try {
67+
script.sh 'pipenv --rm'
68+
} catch (ignored) {}
69+
}
70+
71+
return result
72+
}
73+
74+
/**
75+
* Run a closure with Pipenv using multiple versions of Python. Because the virtualenv
76+
* created by Pipenv must be wiped out between runs, this function cannot be
77+
* parallelized and therefore the commands are run serially for each Python version.
78+
*
79+
* @param args Map of arguments. Valid arguments include:
80+
* <ul>
81+
* <li>
82+
* {@code pythonVersions}: List of Python versions to run the command
83+
* with. This argument is passed to Pipenv via {@code pipenv --python}.
84+
* See {@code pipenv --help} for supported syntax.
85+
* <strong>Required value, may not be null!</strong>
86+
* </li>
87+
* </ul>
88+
* @param body Closure body to execute. The closure body is passed the python version
89+
* as an argument.
90+
* @return Map of return values. The keys in the map correspond to the Python versions
91+
* given in {@code args.pythonVersions}, and the values are the result of
92+
* executing the closure body.
93+
*/
94+
Map runWith(Map args, Closure body) {
95+
assert script
96+
assert args
97+
assert args.pythonVersions
98+
99+
Map result = [:]
100+
101+
try {
102+
args.pythonVersions.each { python ->
103+
script.sh "pipenv install --dev --python ${python}"
104+
result[python] = body(python)
105+
}
106+
} finally {
107+
try {
108+
script.sh 'pipenv --rm'
109+
} catch (ignored) {}
110+
}
111+
112+
return result
113+
}
114+
}

test/com/ableton/PipenvTest.groovy

+212
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package com.ableton
2+
3+
import static com.lesfurets.jenkins.unit.MethodCall.callArgsToString
4+
import static org.junit.Assert.assertEquals
5+
import static org.junit.Assert.assertNotNull
6+
import static org.junit.Assert.assertTrue
7+
8+
import com.lesfurets.jenkins.unit.BasePipelineTest
9+
import org.junit.After
10+
import org.junit.Before
11+
import org.junit.Test
12+
13+
14+
class PipenvTest extends BasePipelineTest {
15+
@SuppressWarnings('FieldTypeRequired')
16+
def script
17+
18+
@Override
19+
@Before
20+
void setUp() throws Exception {
21+
super.setUp()
22+
23+
this.script = loadScript('test/resources/EmptyPipeline.groovy')
24+
assertNotNull(script)
25+
helper.registerAllowedMethod('sh', [Map], JenkinsMocks.sh)
26+
helper.registerAllowedMethod('sh', [String], JenkinsMocks.sh)
27+
28+
JenkinsMocks.addShMock('pipenv --rm', '', 0)
29+
}
30+
31+
@After
32+
void tearDown() {
33+
JenkinsMocks.clearStaticData()
34+
}
35+
36+
@Test
37+
void runCommand() throws Exception {
38+
List pythonVersions = ['2.7', '3.5']
39+
pythonVersions.each { python ->
40+
JenkinsMocks.addShMock("pipenv install --dev --python ${python}", '', 0)
41+
}
42+
JenkinsMocks.addShMock('pipenv run ./test.py', '', 0)
43+
44+
new Pipenv(script: script).runCommand(
45+
command: './test.py',
46+
pythonVersions: pythonVersions,
47+
)
48+
49+
pythonVersions.each { python ->
50+
assertTrue(helper.callStack.findAll { call ->
51+
call.methodName == 'sh'
52+
}.any { call ->
53+
callArgsToString(call).contains("pipenv install --dev --python ${python}")
54+
})
55+
}
56+
57+
// We must use a stupid variable name here ('call2') in order to get around CodeNarc's
58+
// slightly buggy CouldBeSwitchStatement rule.
59+
List pipenvRunCalls = helper.callStack.findAll { call2 ->
60+
if (call2.methodName == 'sh') {
61+
return callArgsToString(call2).contains('pipenv run ./test.py')
62+
}
63+
}
64+
assertEquals(pythonVersions.size(), pipenvRunCalls.size())
65+
}
66+
67+
@Test
68+
void runCommandReturnStdout() throws Exception {
69+
List pythonVersions = ['2.7', '3.5']
70+
pythonVersions.each { python ->
71+
JenkinsMocks.addShMock("pipenv install --dev --python ${python}", '', 0)
72+
}
73+
JenkinsMocks.addShMock('pipenv run ./test.py', 'success', 0)
74+
75+
Map result = new Pipenv(script: script).runCommand(
76+
command: './test.py',
77+
pythonVersions: pythonVersions,
78+
returnStdout: true,
79+
)
80+
81+
assertEquals(2, result.size())
82+
pythonVersions.each { python ->
83+
assertEquals('success', result[python])
84+
}
85+
86+
pythonVersions.each { python ->
87+
assertTrue(helper.callStack.findAll { call ->
88+
call.methodName == 'sh'
89+
}.any { call ->
90+
callArgsToString(call).contains("pipenv install --dev --python ${python}")
91+
})
92+
}
93+
List pipenvRunCalls = helper.callStack.findAll { call ->
94+
if (call.methodName == 'sh') {
95+
return callArgsToString(call).contains('pipenv run ./test.py')
96+
}
97+
}
98+
assertEquals(pythonVersions.size(), pipenvRunCalls.size())
99+
}
100+
101+
@Test
102+
void runCommandReturnStatus() throws Exception {
103+
List pythonVersions = ['2.7', '3.5']
104+
pythonVersions.each { python ->
105+
JenkinsMocks.addShMock("pipenv install --dev --python ${python}", '', 0)
106+
}
107+
JenkinsMocks.addShMock('pipenv run ./test.py', '', 3)
108+
109+
Map result = new Pipenv(script: script).runCommand(
110+
command: './test.py',
111+
pythonVersions: pythonVersions,
112+
returnStatus: true,
113+
)
114+
115+
assertEquals(2, result.size())
116+
pythonVersions.each { python ->
117+
assertEquals(3, result[python])
118+
}
119+
120+
// Ensure that pipenv install was called for each Python version
121+
pythonVersions.each { python ->
122+
assertTrue(helper.callStack.findAll { call ->
123+
call.methodName == 'sh'
124+
}.any { call ->
125+
callArgsToString(call).contains("pipenv install --dev --python ${python}")
126+
})
127+
}
128+
129+
// Ensure that pipenv run was called for each Python version
130+
List pipenvRunCalls = helper.callStack.findAll { call ->
131+
if (call.methodName == 'sh') {
132+
return callArgsToString(call).contains('pipenv run ./test.py')
133+
}
134+
}
135+
assertEquals(pythonVersions.size(), pipenvRunCalls.size())
136+
137+
// Ensure that pipenv --rm was called
138+
assertTrue(helper.callStack.findAll { call ->
139+
call.methodName == 'sh'
140+
}.any { call ->
141+
callArgsToString(call).contains('pipenv --rm')
142+
})
143+
}
144+
145+
146+
@Test(expected = AssertionError)
147+
void runCommandNoScript() throws Exception {
148+
new Pipenv().runCommand(script: 'foo', pythonVersions: ['2.7'])
149+
}
150+
151+
@Test(expected = AssertionError)
152+
void runCommandNoCommand() throws Exception {
153+
new Pipenv(script: script).runCommand(pythonVersions: ['2.7'])
154+
}
155+
156+
@Test(expected = AssertionError)
157+
void runCommandNoPythonVersions() throws Exception {
158+
new Pipenv(script: script).runCommand(command: 'foo')
159+
}
160+
161+
@Test(expected = AssertionError)
162+
void runCommandEmptyPythonVersions() throws Exception {
163+
new Pipenv(script: script).runCommand(command: 'foo', pythonVersions: [])
164+
}
165+
166+
@Test
167+
void runWith() throws Exception {
168+
List pythonVersions = ['2.7', '3.5']
169+
pythonVersions.each { python ->
170+
JenkinsMocks.addShMock("pipenv install --dev --python ${python}", '', 0)
171+
}
172+
173+
int numCalls = 0
174+
Map result = new Pipenv(script: script).runWith(pythonVersions: pythonVersions) { p ->
175+
numCalls++
176+
return p
177+
}
178+
179+
// Ensure that pipenv install was called for each Python version
180+
pythonVersions.each { python ->
181+
assertTrue(helper.callStack.findAll { call ->
182+
call.methodName == 'sh'
183+
}.any { call ->
184+
callArgsToString(call).contains("pipenv install --dev --python ${python}")
185+
})
186+
}
187+
188+
// Ensure that the closure body was evaluated for each Python version
189+
assertEquals(pythonVersions.size(), numCalls)
190+
pythonVersions.each { python ->
191+
assert result[python]
192+
assertEquals(python, result[python])
193+
}
194+
195+
// Ensure that pipenv --rm was called
196+
assertTrue(helper.callStack.findAll { call ->
197+
call.methodName == 'sh'
198+
}.any { call ->
199+
callArgsToString(call).contains('pipenv --rm')
200+
})
201+
}
202+
203+
@Test(expected = AssertionError)
204+
void runWithNoScript() throws Exception {
205+
new Pipenv().runWith(pythonVersions: ['2.7']) {}
206+
}
207+
208+
@Test(expected = AssertionError)
209+
void runWithEmptyPythonVersions() throws Exception {
210+
new Pipenv(script: script).runWith(pythonVersions: []) {}
211+
}
212+
}

vars/pipenv.groovy

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import com.ableton.Pipenv
2+
3+
4+
/**
5+
* Run a command with Pipenv using multiple versions of Python.
6+
* @param args Map of arguments.
7+
* @return Map with return output or values for each Python version.
8+
* @see com.ableton.Pipenv#runCommand(Map)
9+
*/
10+
Map runCommand(Map args) {
11+
return new Pipenv(script: this).runCommand(args)
12+
}
13+
14+
15+
/**
16+
* Run a closure with Pipenv using multiple versions of Python.
17+
* @param pythonVersions List of Python versions.
18+
* @param body Closure body to execute.
19+
* @return Map with return output or values for each Python version.
20+
* @see com.ableton.Pipenv#runWith(List, Closure)
21+
*/
22+
Map runWith(List pythonVersions, Closure body) {
23+
return new Pipenv(script: this).runWith(pythonVersions, body)
24+
}

0 commit comments

Comments
 (0)