133780a244b24781f3b8870fde9ef1ae561af3b7
[idea/community.git] / python / src / com / jetbrains / python / packaging / PyPIPackageUtil.java
1 /*
2  * Copyright 2000-2014 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.jetbrains.python.packaging;
17
18 import com.google.common.collect.Lists;
19 import com.google.gson.FieldNamingPolicy;
20 import com.google.gson.Gson;
21 import com.google.gson.GsonBuilder;
22 import com.intellij.openapi.application.ApplicationInfo;
23 import com.intellij.openapi.application.ApplicationManager;
24 import com.intellij.openapi.application.ApplicationNamesInfo;
25 import com.intellij.openapi.diagnostic.Logger;
26 import com.intellij.openapi.util.Pair;
27 import com.intellij.openapi.util.io.FileUtil;
28 import com.intellij.openapi.util.text.StringUtil;
29 import com.intellij.util.CatchingConsumer;
30 import com.intellij.util.containers.ContainerUtil;
31 import com.intellij.util.io.HttpRequests;
32 import com.intellij.webcore.packaging.PackageVersionComparator;
33 import com.intellij.webcore.packaging.RepoPackage;
34 import com.jetbrains.python.PythonHelpersLocator;
35 import org.jetbrains.annotations.NonNls;
36 import org.jetbrains.annotations.NotNull;
37 import org.jetbrains.annotations.Nullable;
38
39 import javax.swing.text.MutableAttributeSet;
40 import javax.swing.text.html.HTML;
41 import javax.swing.text.html.HTMLEditorKit;
42 import javax.swing.text.html.parser.ParserDelegator;
43 import java.io.FileReader;
44 import java.io.IOException;
45 import java.io.Reader;
46 import java.io.UnsupportedEncodingException;
47 import java.net.URLDecoder;
48 import java.util.*;
49 import java.util.regex.Matcher;
50 import java.util.regex.Pattern;
51 import java.util.stream.Collectors;
52
53 /**
54  * User: catherine
55  */
56 public class PyPIPackageUtil {
57   private static final Logger LOG = Logger.getInstance(PyPIPackageUtil.class);
58   private static final Gson GSON = new GsonBuilder()
59     .setFieldNamingStrategy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
60     .create();
61   
62   private static final String PYPI_HOST = "https://pypi.python.org";
63   public static final String PYPI_URL = PYPI_HOST + "/pypi";
64   public static final String PYPI_LIST_URL = PYPI_HOST + "/simple";
65
66   public static final Map<String, String> PACKAGES_TOPLEVEL = new HashMap<>();
67
68   private static final Map<String, List<String>> ourPackageToReleases = new HashMap<>();
69   private static final Set<RepoPackage> ourAdditionalPackageNames = new TreeSet<>();
70
71   public static final PyPIPackageUtil INSTANCE = new PyPIPackageUtil();
72   private final Map<String, PackageDetails> myPackageToDetails = new HashMap<>();
73   @Nullable private volatile Set<String> myPackageNames = null;
74
75
76   static {
77     try {
78       fillPackages();
79     }
80     catch (IOException e) {
81       LOG.error("Cannot find \"packages\". " + e.getMessage());
82     }
83   }
84
85   /**
86    * Prevents simultaneous updates of {@link PyPackageService#PY_PACKAGES}
87    * because the corresponding response contains tons of data and multiple
88    * queries at the same time can cause memory issues. 
89    */
90   private final Object myPyPIPackageCacheUpdateLock = new Object();
91   
92   /**
93    * Value for "User Agent" HTTP header in form: PyCharm/2016.2 EAP
94    */
95   @NotNull
96   private static String getUserAgent() {
97     return ApplicationNamesInfo.getInstance().getProductName() + "/" + ApplicationInfo.getInstance().getFullVersion();
98   }
99
100   private static void fillPackages() throws IOException {
101     try (FileReader reader = new FileReader(PythonHelpersLocator.getHelperPath("/tools/packages"))) {
102       final String text = FileUtil.loadTextAndClose(reader);
103       final List<String> lines = StringUtil.split(text, "\n");
104       for (String line : lines) {
105         final List<String> split = StringUtil.split(line, " ");
106         PACKAGES_TOPLEVEL.put(split.get(0), split.get(1));
107       }
108     }
109   }
110
111   @NotNull
112   private static Pair<String, String> splitNameVersion(@NotNull String pyPackage) {
113     final int dashInd = pyPackage.lastIndexOf("-");
114     if (dashInd >= 0 && dashInd+1 < pyPackage.length()) {
115       final String name = pyPackage.substring(0, dashInd);
116       final String version = pyPackage.substring(dashInd+1);
117       if (StringUtil.containsAlphaCharacters(version)) {
118         return Pair.create(pyPackage, null);
119       }
120       return Pair.create(name, version);
121     }
122     return Pair.create(pyPackage, null);
123   }
124
125   public static boolean isPyPIRepository(@Nullable String repository) {
126     return repository != null && repository.startsWith(PYPI_HOST);
127   }
128
129   private static void fillAdditionalPackages(@NotNull String url) throws IOException {
130     final boolean simpleIndex = url.endsWith("simple/");
131     final List<String> packagesList = parsePyPIListFromWeb(url, simpleIndex);
132
133     for (String pyPackage : packagesList) {
134       if (simpleIndex) {
135         final Pair<String, String> nameVersion = splitNameVersion(StringUtil.trimTrailing(pyPackage, '/'));
136         ourAdditionalPackageNames.add(new RepoPackage(nameVersion.getFirst(), url, nameVersion.getSecond()));
137       }
138       else {
139         try {
140           final Pattern repositoryPattern = Pattern.compile(url + "([^/]*)/([^/]*)$");
141           final Matcher matcher = repositoryPattern.matcher(URLDecoder.decode(pyPackage, "UTF-8"));
142           if (matcher.find()) {
143             final String packageName = matcher.group(1);
144             final String packageVersion = matcher.group(2);
145             if (!packageName.contains(" "))
146               ourAdditionalPackageNames.add(new RepoPackage(packageName, url, packageVersion));
147           }
148         }
149         catch (UnsupportedEncodingException e) {
150           LOG.warn(e.getMessage());
151         }
152       }
153     }
154   }
155
156   @NotNull
157   public Set<RepoPackage> getAdditionalPackageNames() throws IOException {
158     if (ourAdditionalPackageNames.isEmpty()) {
159       for (String url : PyPackageService.getInstance().additionalRepositories) {
160         fillAdditionalPackages(url);
161       }
162     }
163     return ourAdditionalPackageNames;
164   }
165
166   public void clearPackagesCache() {
167     PyPackageService.getInstance().PY_PACKAGES.clear();
168     ourAdditionalPackageNames.clear();
169   }
170
171   public void fillPackageDetails(@NotNull String packageName, @NotNull CatchingConsumer<PackageDetails.Info, Exception> callback) {
172     ApplicationManager.getApplication().executeOnPooledThread(() -> {
173       try {
174         final PackageDetails packageDetails = refreshAndGetPackageDetailsFromPyPI(packageName, false);
175         callback.consume(packageDetails.getInfo());
176       }
177       catch (IOException e) {
178         callback.consume(e);
179       }
180     });
181   }
182
183   @NotNull
184   private PackageDetails refreshAndGetPackageDetailsFromPyPI(@NotNull String packageName, boolean alwaysRefresh) throws IOException {
185     PackageDetails details = myPackageToDetails.get(packageName);
186     if (alwaysRefresh || details == null) {
187       details = HttpRequests.request(PYPI_URL + "/" + packageName + "/json")
188         .userAgent(getUserAgent())
189         .connect(request -> GSON.fromJson(request.getReader(), PackageDetails.class));
190       myPackageToDetails.put(packageName, details);
191     }
192     return details;
193   }
194
195   public void addPackageReleases(@NotNull String packageName, @NotNull List<String> releases) {
196     ourPackageToReleases.put(packageName, releases);
197   }
198
199   public void usePackageReleases(@NotNull String packageName, @NotNull CatchingConsumer<List<String>, Exception> callback) {
200     ApplicationManager.getApplication().executeOnPooledThread(() -> {
201       try {
202         final List<String> releasesFromSimpleIndex = getPackageVersionsFromAdditionalRepositories(packageName);
203         if (releasesFromSimpleIndex == null) {
204           final List<String> releasesFromPyPI = getPackageVersionsFromPyPI(packageName, true);
205           callback.consume(releasesFromPyPI);
206         }
207         else {
208           callback.consume(releasesFromSimpleIndex);
209         }
210       }
211       catch (Exception e) {
212         callback.consume(e);
213       }
214     });
215   }
216
217   /**
218    * Fetches available package versions using JSON API of PyPI.
219    */
220   @NotNull
221   private List<String> getPackageVersionsFromPyPI(@NotNull String packageName, 
222                                                   boolean force) throws IOException {
223     final PackageDetails details = refreshAndGetPackageDetailsFromPyPI(packageName, force);
224     final List<String> result = details.getReleases();
225     result.sort(PackageVersionComparator.VERSION_COMPARATOR.reversed());
226     return Collections.unmodifiableList(result);
227   }
228
229   @Nullable
230   private String getLatestPackageVersionFromPyPI(@NotNull String packageName) throws IOException {
231     LOG.debug("Requesting the latest PyPI version for the package " + packageName);
232     final List<String> versions = getPackageVersionsFromPyPI(packageName, true);
233     final String latest = ContainerUtil.getFirstItem(versions);
234     getPyPIPackages().put(packageName, StringUtil.notNullize(latest));
235     return latest;
236   }
237
238   /**
239    * Fetches available package versions by scrapping the page containing package archives. 
240    * It's primarily used for additional repositories since, e.g. devpi doesn't provide another way to get this information.
241    */
242   @Nullable
243   private static List<String> getPackageVersionsFromAdditionalRepositories(@NotNull @NonNls String packageName) throws IOException {
244     if (ourPackageToReleases.containsKey(packageName)) {
245       return ourPackageToReleases.get(packageName);
246     }
247     final List<String> repositories = PyPackageService.getInstance().additionalRepositories;
248     for (String repository : repositories) {
249       final List<String> versions = parsePackageVersionsFromArchives(composeSimpleUrl(packageName, repository));
250       if (!versions.isEmpty()) {
251         ourPackageToReleases.put(packageName, versions);
252         return versions;
253       }
254     }
255     return null;
256   }
257
258   @Nullable
259   private static String getLatestPackageVersionFromAdditionalRepositories(@NotNull String packageName) throws IOException {
260     final List<String> versions = getPackageVersionsFromAdditionalRepositories(packageName);
261     return ContainerUtil.getFirstItem(versions);
262   }
263
264   @Nullable
265   public String fetchLatestPackageVersion(@NotNull String packageName) throws IOException {
266     String version = getPyPIPackages().get(packageName);
267     // Package is on PyPI but it's version is unknown
268     if (version != null && version.isEmpty()) {
269       version = getLatestPackageVersionFromPyPI(packageName);
270     }
271     final String extraVersion = getLatestPackageVersionFromAdditionalRepositories(packageName);
272     if (extraVersion != null) {
273       version = extraVersion;
274     }
275     return version;
276   }
277
278   @NotNull
279   private static List<String> parsePackageVersionsFromArchives(@NotNull String archivesUrl) throws IOException {
280     return HttpRequests.request(archivesUrl).userAgent(getUserAgent()).connect(request -> {
281       final List<String> versions = new ArrayList<>();
282       final Reader reader = request.getReader();
283       new ParserDelegator().parse(reader, new HTMLEditorKit.ParserCallback() {
284         HTML.Tag myTag;
285
286         @Override
287         public void handleStartTag(HTML.Tag tag, MutableAttributeSet set, int i) {
288           myTag = tag;
289         }
290
291         @Override
292         public void handleText(@NotNull char[] data, int pos) {
293           if (myTag != null && "a".equals(myTag.toString())) {
294             String packageVersion = String.valueOf(data);
295             final String suffix = ".tar.gz";
296             if (!packageVersion.endsWith(suffix)) return;
297             packageVersion = StringUtil.trimEnd(packageVersion, suffix);
298             versions.add(splitNameVersion(packageVersion).second);
299           }
300         }
301       }, true);
302       versions.sort(PackageVersionComparator.VERSION_COMPARATOR.reversed());
303       return versions;
304     });
305   }
306
307   @NotNull
308   private static String composeSimpleUrl(@NonNls @NotNull String packageName, @NotNull String rep) {
309     String suffix = "";
310     final String repository = StringUtil.trimEnd(rep, "/");
311     if (!repository.endsWith("+simple") && !repository.endsWith("/simple")) {
312       suffix = "/+simple";
313     }
314     suffix += "/" + packageName;
315     return repository + suffix;
316   }
317
318   public void updatePyPICache(@NotNull PyPackageService service) throws IOException {
319     service.LAST_TIME_CHECKED = System.currentTimeMillis();
320
321     service.PY_PACKAGES.clear();
322     if (service.PYPI_REMOVED) return;
323     parsePyPIList(parsePyPIListFromWeb(PYPI_LIST_URL, true), service);
324   }
325
326   private void parsePyPIList(@NotNull List<String> packages, @NotNull PyPackageService service) {
327     myPackageNames = null;
328     for (String pyPackage : packages) {
329       try {
330         final String packageName = URLDecoder.decode(pyPackage, "UTF-8");
331         if (!packageName.contains(" ")) {
332           service.PY_PACKAGES.put(packageName, "");
333         }
334       }
335       catch (UnsupportedEncodingException e) {
336         LOG.warn(e.getMessage());
337       }
338     }
339   }
340
341   @NotNull
342   private static List<String> parsePyPIListFromWeb(@NotNull String url, boolean isSimpleIndex) throws IOException {
343     return HttpRequests.request(url).userAgent(getUserAgent()).connect(request -> {
344       final List<String> packages = new ArrayList<>();
345       final Reader reader = request.getReader();
346       new ParserDelegator().parse(reader, new HTMLEditorKit.ParserCallback() {
347         boolean inTable = false;
348         HTML.Tag myTag;
349
350         @Override
351         public void handleStartTag(@NotNull HTML.Tag tag, @NotNull MutableAttributeSet set, int i) {
352           myTag = tag;
353           if (!isSimpleIndex) {
354             if ("table".equals(tag.toString())) {
355               inTable = !inTable;
356             }
357
358             if (inTable && "a".equals(tag.toString())) {
359               packages.add(String.valueOf(set.getAttribute(HTML.Attribute.HREF)));
360             }
361           }
362         }
363
364         @Override
365         public void handleText(@NotNull char[] data, int pos) {
366           if (isSimpleIndex) {
367             if (myTag != null && "a".equals(myTag.toString())) {
368               packages.add(String.valueOf(data));
369             }
370           }
371         }
372
373         @Override
374         public void handleEndTag(@NotNull HTML.Tag tag, int i) {
375           if (!isSimpleIndex) {
376             if ("table".equals(tag.toString())) {
377               inTable = !inTable;
378             }
379           }
380         }
381       }, true);
382       return packages;
383     });
384   }
385
386   @NotNull
387   public Collection<String> getPackageNames() {
388     final Map<String, String> pyPIPackages = getPyPIPackages();
389     final ArrayList<String> list = Lists.newArrayList(pyPIPackages.keySet());
390     Collections.sort(list);
391     return list;
392   }
393
394   @NotNull
395   public Map<String, String> loadAndGetPackages() throws IOException {
396     Map<String, String> pyPIPackages = getPyPIPackages();
397     synchronized (myPyPIPackageCacheUpdateLock) {
398       if (pyPIPackages.isEmpty()) {
399         updatePyPICache(PyPackageService.getInstance());
400         pyPIPackages = getPyPIPackages();
401       }
402     }
403     return pyPIPackages;
404   }
405
406   @NotNull
407   public static Map<String, String> getPyPIPackages() {
408     return PyPackageService.getInstance().PY_PACKAGES;
409   }
410
411   public boolean isInPyPI(@NotNull String packageName) {
412     if (myPackageNames == null) {
413       myPackageNames = getPyPIPackages().keySet().stream().map(name -> name.toLowerCase(Locale.ENGLISH)).collect(Collectors.toSet());
414     }
415     return myPackageNames != null && myPackageNames.contains(packageName.toLowerCase(Locale.ENGLISH));
416   }
417
418   @SuppressWarnings("FieldMayBeFinal")
419   public static final class PackageDetails {
420     public static final class Info {
421       private String version = "";
422       private String author = "";
423
424       private String authorEmail = "";
425       private String homePage = "";
426       private String summary = "";
427
428       
429       @NotNull
430       public String getVersion() {
431         return version;
432       }
433
434       @NotNull
435       public String getAuthor() {
436         return author;
437       }
438
439       @NotNull
440       public String getAuthorEmail() {
441         return authorEmail;
442       }
443
444       @NotNull
445       public String getHomePage() {
446         return homePage;
447       }
448
449       @NotNull
450       public String getSummary() {
451         return summary;
452       }
453     }
454
455     private Info info = new Info();
456     private Map<String, Object> releases = Collections.emptyMap();
457
458     @NotNull
459     public Info getInfo() {
460       return info;
461     }
462
463     @NotNull
464     public List<String> getReleases() {
465       return new ArrayList<>(releases.keySet());
466     }
467   }
468 }