Skip to content

Commit 3aa3105

Browse files
authored
Merge pull request #326 from pyupio/nicholas/adding-documentation-license-command
License command documentation and cache
2 parents 2e5b46b + df6c5c3 commit 3aa3105

File tree

7 files changed

+279
-9
lines changed

7 files changed

+279
-9
lines changed

HISTORY.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ History
99
* Added README information about Python 2.7 workaround
1010
* Adjusted some pricing information
1111
* Fixed MacOS binary build through AppVeyor
12+
* Added the ability to check packages licenses
1213

1314
1.9.0 (2020-04-27)
1415
------------------

README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,111 @@ safety review --file report.json --bare
430430
```
431431
django
432432
```
433+
434+
___
435+
436+
# License
437+
438+
Display packages licenses information (requires an api-key)
439+
440+
## Options
441+
442+
### `--key` (REQUIRED)
443+
444+
*API Key for pyup.io's licenses database. Can be set as `SAFETY_API_KEY` environment variable.*
445+
446+
**Example**
447+
```bash
448+
safety license --key=12345-ABCDEFGH
449+
```
450+
*Shows the license of each package in the current environment*
451+
452+
453+
```
454+
+==============================================================================+
455+
| |
456+
| /$$$$$$ /$$ |
457+
| /$$__ $$ | $$ |
458+
| /$$$$$$$ /$$$$$$ | $$ \__//$$$$$$ /$$$$$$ /$$ /$$ |
459+
| /$$_____/ |____ $$| $$$$ /$$__ $$|_ $$_/ | $$ | $$ |
460+
| | $$$$$$ /$$$$$$$| $$_/ | $$$$$$$$ | $$ | $$ | $$ |
461+
| \____ $$ /$$__ $$| $$ | $$_____/ | $$ /$$| $$ | $$ |
462+
| /$$$$$$$/| $$$$$$$| $$ | $$$$$$$ | $$$$/| $$$$$$$ |
463+
| |_______/ \_______/|__/ \_______/ \___/ \____ $$ |
464+
| /$$ | $$ |
465+
| | $$$$$$/ |
466+
| by pyup.io \______/ |
467+
| |
468+
+==============================================================================+
469+
| Packages licenses |
470+
+=============================================+===========+====================+
471+
| package | version | license |
472+
+=============================================+===========+====================+
473+
| requests | 2.25.0 | Apache-2.0 |
474+
|------------------------------------------------------------------------------|
475+
| click | 7.1.2 | BSD-3-Clause |
476+
|------------------------------------------------------------------------------|
477+
| safety | 1.10.0.de | MIT |
478+
+==============================================================================+
479+
```
480+
481+
___
482+
483+
### `--db`
484+
485+
*Path to a directory with a local licenses database `licenses.json`*
486+
487+
**Example**
488+
```bash
489+
safety license --key=12345-ABCDEFGH --db=/home/safety-db/data
490+
```
491+
___
492+
493+
### `--no-cache`
494+
495+
*Since PyUp.io licenses DB is updated once a week, the licenses database is cached locally for 7 days. You can use `--no-cache` to download it once again.*
496+
497+
**Example**
498+
```bash
499+
safety license --key=12345-ABCDEFGH --no-cache
500+
```
501+
___
502+
503+
### `--file`, `-r`
504+
505+
*Read input from one (or multiple) requirement files.*
506+
507+
**Example**
508+
```bash
509+
safety license --key=12345-ABCDEFGH -r requirements.txt
510+
```
511+
```bash
512+
safety license --key=12345-ABCDEFGH --file=requirements.txt
513+
```
514+
```bash
515+
safety license --key=12345-ABCDEFGH -r req_dev.txt -r req_prod.txt
516+
```
517+
518+
___
519+
520+
521+
### `--proxy-host`, `-ph`
522+
523+
*Proxy host IP or DNS*
524+
525+
### `--proxy-port`, `-pp`
526+
527+
*Proxy port number*
528+
529+
### `--proxy-protocol`, `-pr`
530+
531+
*Proxy protocol (https or http)*
532+
533+
**Example**
534+
```bash
535+
safety license --key=12345-ABCDEFGH -ph 127.0.0.1 -pp 8080 -pr https
536+
```
537+
433538
___
434539

435540
# Python 2.7

safety/cli.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from safety.formatter import report, license_report
88
import itertools
99
from safety.util import read_requirements, read_vulnerabilities, get_proxy_dict, get_packages_licenses
10-
from safety.errors import DatabaseFetchError, DatabaseFileNotFoundError, InvalidKeyError
10+
from safety.errors import DatabaseFetchError, DatabaseFileNotFoundError, InvalidKeyError, TooManyRequestsError
1111

1212
try:
1313
from json.decoder import JSONDecodeError
@@ -123,7 +123,7 @@ def review(full_report, bare, file):
123123

124124

125125
@cli.command()
126-
@click.option("--key", default="", envvar="SAFETY_API_KEY",
126+
@click.option("--key", required=True, envvar="SAFETY_API_KEY",
127127
help="API Key for pyup.io's vulnerability database. Can be set as SAFETY_API_KEY "
128128
"environment variable. Default: empty")
129129
@click.option("--db", default="",
@@ -151,7 +151,26 @@ def license(key, db, cache, files, proxyprotocol, proxyhost, proxyport):
151151
]
152152

153153
proxy_dictionary = get_proxy_dict(proxyprotocol, proxyhost, proxyport)
154-
licenses_db = safety.get_licenses(key, db, cache, proxy_dictionary)
154+
try:
155+
licenses_db = safety.get_licenses(key, db, cache, proxy_dictionary)
156+
except InvalidKeyError:
157+
click.secho("Your API Key '{key}' is invalid. See {link}".format(
158+
key=key, link='https://goo.gl/O7Y1rS'),
159+
fg="red",
160+
file=sys.stderr)
161+
sys.exit(-1)
162+
except DatabaseFileNotFoundError:
163+
click.secho("Unable to load licenses database from {db}".format(db=db), fg="red", file=sys.stderr)
164+
sys.exit(-1)
165+
except TooManyRequestsError:
166+
click.secho("Unable to load licenses database (Too many requests, please wait before another request)",
167+
fg="red",
168+
file=sys.stderr
169+
)
170+
sys.exit(-1)
171+
except DatabaseFetchError:
172+
click.secho("Unable to load licenses database", fg="red", file=sys.stderr)
173+
sys.exit(-1)
155174
filtered_packages_licenses = get_packages_licenses(packages, licenses_db)
156175
output_report = license_report(packages=packages, licenses=filtered_packages_licenses)
157176
click.secho(output_report, nl=True)

safety/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
CACHE_VALID_SECONDS = 60 * 60 * 2 # 2 hours
1515

16+
CACHE_LICENSES_VALID_SECONDS = 60 * 60 * 24 * 7 # one week
17+
1618
CACHE_FILE = os.path.join(
1719
os.path.expanduser("~"),
1820
".safety",

safety/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ class DatabaseFileNotFoundError(DatabaseFetchError):
88

99
class InvalidKeyError(DatabaseFetchError):
1010
pass
11+
12+
13+
class TooManyRequestsError(DatabaseFetchError):
14+
pass

safety/safety.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
import requests
99
from packaging.specifiers import SpecifierSet
1010

11-
from .constants import (API_MIRRORS, CACHE_FILE, CACHE_VALID_SECONDS,
12-
OPEN_MIRRORS, REQUEST_TIMEOUT)
11+
from .constants import (API_MIRRORS, CACHE_FILE, CACHE_LICENSES_VALID_SECONDS,
12+
CACHE_VALID_SECONDS, OPEN_MIRRORS, REQUEST_TIMEOUT)
1313
from .errors import (DatabaseFetchError, DatabaseFileNotFoundError,
14-
InvalidKeyError)
14+
InvalidKeyError, TooManyRequestsError)
1515
from .util import RequirementFile
1616

1717

@@ -27,7 +27,13 @@ def get_from_cache(db_name):
2727
data = json.loads(f.read())
2828
if db_name in data:
2929
if "cached_at" in data[db_name]:
30-
if data[db_name]["cached_at"] + CACHE_VALID_SECONDS > time.time():
30+
if 'licenses.json' in db_name:
31+
# Getting the specific cache time for the licenses db.
32+
cache_valid_seconds = CACHE_LICENSES_VALID_SECONDS
33+
else:
34+
cache_valid_seconds = CACHE_VALID_SECONDS
35+
36+
if data[db_name]["cached_at"] + cache_valid_seconds > time.time():
3137
return data[db_name]["db"]
3238
except json.JSONDecodeError:
3339
pass
@@ -89,6 +95,8 @@ def fetch_database_url(mirror, db_name, key, cached, proxy):
8995
return data
9096
elif r.status_code == 403:
9197
raise InvalidKeyError()
98+
elif r.status_code == 429:
99+
raise TooManyRequestsError()
92100

93101

94102
def fetch_database_file(path, db_name):
@@ -181,8 +189,8 @@ def review(vulnerabilities):
181189
def get_licenses(key, db_mirror, cached, proxy):
182190
key = key if key else os.environ.get("SAFETY_API_KEY", False)
183191

184-
if not key:
185-
raise DatabaseFetchError("API-KEY not provided.")
192+
if not key and not db_mirror:
193+
raise InvalidKeyError("API-KEY not provided.")
186194
if db_mirror:
187195
mirrors = [db_mirror]
188196
else:

tests/test_safety.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import unittest
1313
import textwrap
1414
from click.testing import CliRunner
15+
from unittest.mock import Mock, patch
1516

1617
from safety import safety
1718
from safety import cli
@@ -256,6 +257,136 @@ def test_get_packages_licenses(self):
256257
"unexpected package '" + pkg_license['package'] + "' was found"
257258
)
258259

260+
def test_get_packages_licenses_without_api_key(self):
261+
from safety.errors import InvalidKeyError
262+
263+
# without providing an API-KEY
264+
with self.assertRaises(InvalidKeyError) as error:
265+
safety.get_licenses(
266+
db_mirror=False,
267+
cached=False,
268+
proxy={},
269+
key=None
270+
)
271+
db_generic_exception = error.exception
272+
self.assertEqual(str(db_generic_exception), 'API-KEY not provided.')
273+
274+
@patch("safety.safety.requests")
275+
def test_get_packages_licenses_with_invalid_api_key(self, requests):
276+
from safety.errors import InvalidKeyError
277+
278+
mock = Mock()
279+
mock.status_code = 403
280+
requests.get.return_value = mock
281+
282+
# proving an invalid API-KEY
283+
with self.assertRaises(InvalidKeyError):
284+
safety.get_licenses(
285+
db_mirror=False,
286+
cached=False,
287+
proxy={},
288+
key="INVALID"
289+
)
290+
291+
@patch("safety.safety.requests")
292+
def test_get_packages_licenses_db_fetch_error(self, requests):
293+
from safety.errors import DatabaseFetchError
294+
295+
mock = Mock()
296+
mock.status_code = 500
297+
requests.get.return_value = mock
298+
299+
with self.assertRaises(DatabaseFetchError):
300+
safety.get_licenses(
301+
db_mirror=False,
302+
cached=False,
303+
proxy={},
304+
key="MY-VALID-KEY"
305+
)
306+
307+
def test_get_packages_licenses_with_invalid_db_file(self):
308+
from safety.errors import DatabaseFileNotFoundError
309+
310+
with self.assertRaises(DatabaseFileNotFoundError):
311+
safety.get_licenses(
312+
db_mirror='/my/invalid/path',
313+
cached=False,
314+
proxy={},
315+
key=None
316+
)
317+
318+
@patch("safety.safety.requests")
319+
def test_get_packages_licenses_very_often(self, requests):
320+
from safety.errors import TooManyRequestsError
321+
322+
# if the request is made too often, an 429 error is raise by PyUp.io
323+
mock = Mock()
324+
mock.status_code = 429
325+
requests.get.return_value = mock
326+
327+
with self.assertRaises(TooManyRequestsError):
328+
safety.get_licenses(
329+
db_mirror=False,
330+
cached=False,
331+
proxy={},
332+
key="MY-VALID-KEY"
333+
)
334+
335+
@patch("safety.safety.requests")
336+
def test_get_cached_packages_licenses(self, requests):
337+
import copy
338+
from safety.constants import CACHE_FILE
339+
340+
licenses_db = {
341+
"licenses": {
342+
"BSD-3-Clause": 2
343+
},
344+
"packages": {
345+
"django": [
346+
{
347+
"start_version": "0.0",
348+
"license_id": 2
349+
}
350+
]
351+
}
352+
}
353+
original_db = copy.deepcopy(licenses_db)
354+
355+
mock = Mock()
356+
mock.json.return_value = licenses_db
357+
mock.status_code = 200
358+
requests.get.return_value = mock
359+
360+
# lets clear the cache first
361+
try:
362+
with open(CACHE_FILE, 'w') as f:
363+
f.write(json.dumps({}))
364+
except Exception:
365+
pass
366+
367+
# In order to cache the db (and get), we must set cached as True
368+
response = safety.get_licenses(
369+
db_mirror=False,
370+
cached=True,
371+
proxy={},
372+
key="MY-VALID-KEY"
373+
)
374+
self.assertEqual(response, licenses_db)
375+
376+
# now we should have the db in cache
377+
# changing the "live" db to test if we are getting the cached db
378+
licenses_db['licenses']['BSD-3-Clause'] = 123
379+
380+
resp = safety.get_licenses(
381+
db_mirror=False,
382+
cached=True,
383+
proxy={},
384+
key="MY-VALID-KEY"
385+
)
386+
387+
self.assertNotEqual(resp, licenses_db)
388+
self.assertEqual(resp, original_db)
389+
259390

260391
class ReadRequirementsTestCase(unittest.TestCase):
261392

0 commit comments

Comments
 (0)