forked from openrewrite/rewrite-github-actions
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSetupPythonUpgradePythonVersion.java
More file actions
126 lines (109 loc) · 5.83 KB
/
Copy pathSetupPythonUpgradePythonVersion.java
File metadata and controls
126 lines (109 loc) · 5.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/*
* Copyright 2025 the original author or authors.
* <p>
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openrewrite.github;
import lombok.EqualsAndHashCode;
import lombok.Value;
import org.openrewrite.*;
import org.openrewrite.semver.Semver;
import org.openrewrite.yaml.JsonPathMatcher;
import org.openrewrite.yaml.YamlVisitor;
import org.openrewrite.yaml.trait.BlockScalar;
import org.openrewrite.yaml.tree.Yaml;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.openrewrite.semver.Semver.Ecosystem.MAVEN;
import static org.openrewrite.semver.Semver.Ecosystem.NODE;
@EqualsAndHashCode(callSuper = false)
@Value
public class SetupPythonUpgradePythonVersion extends Recipe {
private static final JsonPathMatcher PYTHON_VERSION = new JsonPathMatcher("..steps[?(@.uses =~ 'actions/setup-python@v*.*')].with.python-version");
private static final Pattern MAJOR_MINOR = Pattern.compile("[0-9]+\\.[0-9]+");
// Concrete versions only; `Semver.isVersion` also accepts ranges such as `3.x` and `3.7 - 3.9`
private static final Pattern CPYTHON_VERSION = Pattern.compile("[0-9]+\\.[0-9]+(\\.[0-9]+)?([-+].*)?");
private static final Pattern NON_UPPER_BOUND_RANGE_VERSION = Pattern.compile("(?:^|[\\s,])(?:>=|>|[~^])?\\s*[v=]?([0-9]+(?:\\.[0-9]+){0,2})");
private static final String ABOVE_ANY_PYTHON_VERSION = "999.999.999";
@Option(displayName = "Python version",
description = "The target Python version.",
example = "3.14",
required = true)
String version;
String displayName = "Upgrade `actions/setup-python` `python-version`";
String description = "Update the Python version used by `actions/setup-python` if it is below the expected version number.";
Set<String> tags = new LinkedHashSet<>(Arrays.asList("github", "python", "deprecation"));
@Override
public Validated<Object> validate() {
return super.validate()
.and(Validated.test("version", "must be a major.minor Python version", version,
v -> v != null && MAJOR_MINOR.matcher(v).matches()));
}
@Override
public TreeVisitor<?, ExecutionContext> getVisitor() {
return Preconditions.check(new IsGitHubActionsWorkflow(), new YamlVisitor<ExecutionContext>() {
@Override
public Yaml visitMappingEntry(Yaml.Mapping.Entry entry, ExecutionContext ctx) {
if (!"python-version".equals(entry.getKey().getValue()) ||
!(entry.getValue() instanceof Yaml.Scalar) ||
!PYTHON_VERSION.matches(getCursor()) ||
hasPythonVersionFile()) {
return super.visitMappingEntry(entry, ctx);
}
Yaml.Scalar currentValue = (Yaml.Scalar) entry.getValue();
// A block scalar's raw value carries the block envelope, so its body is read and written through the trait
Optional<BlockScalar> blockScalar = new BlockScalar.Matcher().get(currentValue, getCursor());
if (blockScalar.isPresent()) {
BlockScalar block = blockScalar.get();
String body = block.getBody();
// Several versions in one block is a deliberate matrix, rather than a single version to raise
if (!body.contains("\n") && isBelowTarget(body)) {
return super.visitMappingEntry(entry.withValue(block.withBody(version)), ctx);
}
} else if (isBelowTarget(currentValue.getValue())) {
return super.visitMappingEntry(entry.withValue(currentValue.withValue(version)), ctx);
}
return super.visitMappingEntry(entry, ctx);
}
private boolean hasPythonVersionFile() {
Yaml.Mapping with = getCursor().getParentOrThrow().getValue();
return with.getEntries().stream()
.anyMatch(e -> "python-version-file".equals(e.getKey().getValue()));
}
});
}
private boolean isBelowTarget(String currentVersion) {
// Maven precedence orders `major.minor`, which is not strict SemVer; ranges follow the npm grammar `setup-python` documents
if (CPYTHON_VERSION.matcher(currentVersion).matches()) {
return Semver.compare(currentVersion, version, MAVEN) < 0;
}
// A union or any lower/base version at or above the target could select a newer Python and must not be downgraded
if (currentVersion.contains("||")) {
return false;
}
Matcher rangeVersion = NON_UPPER_BOUND_RANGE_VERSION.matcher(currentVersion);
while (rangeVersion.find()) {
if (Semver.compare(rangeVersion.group(1), version, MAVEN) >= 0) {
return false;
}
}
// No comparator exposes a range's upper bound, so a range is only raised when it admits neither the target nor an implausibly high version
return Semver.validate(currentVersion, null, NODE).isValid() &&
!Semver.satisfies(version + ".0", currentVersion, NODE) &&
!Semver.satisfies(ABOVE_ANY_PYTHON_VERSION, currentVersion, NODE);
}
}