From 86c72aecafabe3964fc62b82e8154dd07aac7f43 Mon Sep 17 00:00:00 2001 From: Till Korten Date: Wed, 24 Apr 2019 16:22:12 +0200 Subject: [PATCH 1/4] added selenium plugin for advanced website operations --- flexget/plugins/operate/selenium.py | 298 ++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 flexget/plugins/operate/selenium.py diff --git a/flexget/plugins/operate/selenium.py b/flexget/plugins/operate/selenium.py new file mode 100644 index 0000000000..f1255afb77 --- /dev/null +++ b/flexget/plugins/operate/selenium.py @@ -0,0 +1,298 @@ +from __future__ import unicode_literals, division, absolute_import +from builtins import * # noqa pylint: disable=unused-import, redefined-builtin + +from os import environ +import logging +from http.cookiejar import CookieJar, Cookie + +from flexget import plugin +from flexget.event import event +from pprint import pformat + +log = logging.getLogger('selenium') + + +class title_is_not(object): + ''' + Helper class for WebDriverWait + An expectation for checking the title of a page. + Title is not the given title + returns True if the title does not match, false otherwise. + ''' + + def __init__(self, title): + self.title = title + + def __call__(self, driver): + return self.title != driver.title + + +class Selenium(object): + ''' + #Selenium: helper plugin for cloudflare DDOS protection and website logins + The selenium plugin uses the browser-automation framework selenium to emulate an actual browser interaction with + certain websites. This helps to circumvent cloudflare DDOS protection and certain complicated login procedures. + + ## Configuration + + Currently, the following actions are supported: + 1. `cloudflare_ddos`: circumvent cloudflare protection. Parameters: 'url`: url to page that is protected by cloudflare + 2. `imdb_login`: log in to imdb. So that the rest of the task can be performed as logged in user. Parameters: `username`: email used for login, `password`: password used for login. + 3. `login`: log in to arbirtrary page. Parameters: `url`: url to login page, `username`: user name (or email) used for login, `password`: password used for login, `input_username_id`: id tag of field where username (or email) is to be entered, `input_password_id`: id tag of field where password is to be entered, `login_button_id`: id tag of button that must be clicked in order to log in, `element_after_login_id`: id of an element that appears on the page only after login, used to wait for successful loading of the page after login. + + **Configuration example for cloudflare DDOS protection:** + ```yaml + selenium: + action: cloudflare_ddos + parameters: + url: http://example.com + ``` + + **Configuration example for imdb login** + ```yaml + selenium: + action: imdb_login + parameters: + username: me@example.com + password: YouWon'tGuess + ``` + + **Configuration example for general login** + ```yaml + selenium: + action: login + parameters: + url: http://example.com/login + username: MyName + password: YouWon'tGuess + input_username_id: username_input_field_id + input_password_id: password_input_field_id + login_button_id: button_id + element_after_login_id: logout_button_id + ``` + + **Configuring the selenium browser backend via environment variables** + The selenium backend is configured via environment variables, which makes it possible to easily configure it within + docker containers and to have flexget and the browser run in different containers. + ```bash + SELENIUM_REMOTE_URL=http://geckodriver-alpine:4444 # host where the remote driver is running. + SELENIUM_BROWSER= # this can be used to configure which driver to use (either locally or remotely if SELENIUM_REMOTE_URL is set). + ``` + + ## Requirements + Needs the selenium python module `pip install selenium`. + Also needs geckodriver and firefox installed on the same machine or running within a dockerimage. + I am using the following `docker-compose.yml` file: + ```yaml + version: "3" + services: + docker-flexget: + image: docker-flexget:latest #this is a self-generated image that contains the python selenium package + restart: unless-stopped + volumes: + - /home//.flexget:/config + environment: + - TZ=Europe/Berlin + - SELENIUM_REMOTE_URL=http://selenium:4444/wd/hub + - PUID=1000 + - PGID=1000 + depends_on: + - selenium + ports: + - 3539:3539 + + selenium: + image: selenium/standalone-firefox + restart: unless-stopped + volumes: + - /dev/shm:/dev/shm + environment: + - TZ=Europe/Berlin + ports: + - 4444:4444 + ``` + ''' + schema = { + 'type': 'object', + 'properties': {'chromedriver': {'type': 'string'}, 'action': {'type': 'string'}, 'parameters': {'type': 'object'}}, + 'additionalProperties': False, + } + + config = {} + + implemented_actions = ['cloudflare_ddos', 'login', 'imdb_login'] + + cj = CookieJar() + + @plugin.priority(253) + def on_task_start(self, task, config): + ''' + Constructor + @param task: the flexget task object + @param config: the configuration for the selenium plugin + ''' + try: + from selenium import webdriver + from selenium.webdriver.support.ui import WebDriverWait + from selenium.common.exceptions import WebDriverException + except ImportError as e: + log.error('Error importing selenium: %s' % e) + raise plugin.DependencyError( + 'selenium', 'selenium', 'Selenium module required. run "pip install selenium". Also needs chromedriver and chromium (for example in Alpine Linux run: "apk add chromium chromium-chromedriver"). ImportError: %s' % e) + self.config.update(config) + use_firefox = True + if 'SELENIUM_BROWSER' in environ and 'chrom' in environ['SELENIUM_BROWSER'].lower(): + use_firefox = False + try: + if use_firefox: + options = webdriver.FirefoxOptions() + options.headless = True + else: + options = webdriver.ChromeOptions() + options.add_argument('headless') + options.add_argument('disable-gpu') + options.add_argument('no-sandbox') + options.add_argument( + 'user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36') + if 'SELENIUM_REMOTE_URL' in environ: + self.driver = webdriver.Remote(command_executor=environ['SELENIUM_REMOTE_URL'], + desired_capabilities=options.to_capabilities()) + else: + if use_firefox: + self.driver = webdriver.Firefox(firefox_options=options) + else: + self.driver = webdriver.Chrome(chrome_options=options) + except WebDriverException as e: + raise plugin.DependencyError( + 'selenium', 'selenium', 'Selenium could not connect to a driver. Check whether the proper driver (SELENIUM_BROWSER) is installed and in your PATH or whether the remote host (SELENIUM_REMOTE_HOST) and port (SELENIUM_REMOTE_PORT) are properly configured. WebDriverException: %s' % e) + + self.wait = WebDriverWait(self.driver, 10) + if config['action'] in self.implemented_actions: + implemented_action = getattr(self, config['action']) + implemented_action(task, self.config['parameters']) + else: + log.debug('The selenium plugin does not support this action: %s. Allowed values for the "action" config parameter are: %s' % + config['action'], self.implemented_actions) + + def __del__(self): + ''' + Before destroying the class object, we need to close the Selenium driver. + ''' + self.driver.quit() + + def add_selenium_cookie(self, cookie): + ''' + Adds a cookie obtained by selenium to the cookiejar. + @param cookie: a cookie object returned by selenium driver.get_cookie() + ''' + log.debug('Added cookie: %s' % pformat(cookie)) + default_attributes = ['name', 'value', 'secure', 'discard', 'comment', 'comment_url'] + cookie_attributes = {'domain': None, 'domain_specified': False, 'domain_initial_dot': False, + 'path': None, 'path_specified': False, 'port': None, 'port_specified': False, 'expires': None} + if cookie: + if 'domain' in cookie: + cookie_attributes['domain'] = cookie['domain'] + cookie_attributes['domain_specified'] = True + cookie_attributes['domain_initial_dot'] = cookie['domain'][0] == '.' + del cookie['domain'] + if 'path' in cookie: + cookie_attributes['path'] = cookie['path'] + cookie_attributes['path_specified'] = True + del cookie['path'] + if 'port' in cookie: + cookie_attributes['port'] = cookie['port'] + cookie_attributes['port_specified'] = True + del cookie['port'] + if 'expiry' in cookie: + cookie_attributes['expires'] = cookie['expiry'] + del cookie['expiry'] + for attribute in default_attributes: + if attribute in cookie: + cookie_attributes[attribute] = cookie[attribute] + del cookie[attribute] + else: + cookie_attributes[attribute] = None + if cookie: + cookie_attributes['rest'] = cookie + html_cookie = Cookie(version=0, name=cookie_attributes['name'], value=cookie_attributes['value'], domain=cookie_attributes['domain'], domain_specified=cookie_attributes['domain_specified'], domain_initial_dot=cookie_attributes['domain_initial_dot'], secure=cookie_attributes['secure'], path=cookie_attributes['path'], + path_specified=cookie_attributes['path_specified'], port=cookie_attributes['port'], port_specified=cookie_attributes['port_specified'], expires=cookie_attributes['expires'], discard=cookie_attributes['discard'], comment=cookie_attributes['comment'], comment_url=cookie_attributes['comment_url'], rest=cookie_attributes['rest']) + self.cj.set_cookie(html_cookie) + + def cloudflare_ddos(self, task, parameters): + ''' + circumvent cloudflares ddos protection by accessing the site via a browser, waiting until the browser test has + passed and copying the cookies to the task.requests session. + @param task: the task object of the currently running task. + @param parameters: the parametes config dict object should contain the url of the page to be accessed + ''' + from selenium.common.exceptions import TimeoutException + self.driver.get(parameters['url']) + try: + self.wait.until(title_is_not('Just a moment...')) + log.verbose('Passed cloudflare DDOS protection for url: "%s". Page title is now: %s', + parameters['url'], self.driver.title) + except TimeoutException: + log.warning('Could not circumvent cloudflare protection for url: "%s". Page title is now: %s', + parameters['url'], self.driver.title) + + user_agent = self.driver.execute_script("return navigator.userAgent;") + self.add_selenium_cookie(self.driver.get_cookie('__cfduid')) + self.add_selenium_cookie(self.driver.get_cookie('cf_clearance')) + # copy the obtained cookies to flexgets general requests session used by other plugins + task.requests.add_cookiejar(self.cj) + # update the header of flexgets requests session to match the header used to circumvent cloudflare + task.requests.headers.update({'User-Agent': user_agent}) + log.debug('Cookies now stored in task.requests.cookies: %s' % pformat(task.requests.cookies)) + self.driver.quit() + + def login(self, task, parameters): + ''' + Use selenium to log in to an arbirtrary page. Copy all session cookies to the task.requests session so that the + user is logged in for the remainder of the task. + @param task: the task object of the currently running task. + @param parameters: The parameters config dict object should contain: `url`: url to login page, `username`: user name (or email) used for login, `password`: password used for login, `input_username_id`: id tag of field where username (or email) is to be entered, `input_password_id`: id tag of field where password is to be entered, `login_button_id`: id tag of button that must be clicked in order to log in, `element_after_login_id`: id of an element that appears on the page only after login, used to wait for successful loading of the page after login. + ''' + import selenium.webdriver.support.expected_conditions as EC + from selenium.webdriver.common.by import By + from selenium.common.exceptions import TimeoutException + self.driver.get(parameters['url']) + self.driver.save_screenshot('/config/screenshot.png') + input_email = self.driver.find_element_by_id(parameters['input_username_id']) + input_email.send_keys(parameters['username']) + input_password = self.driver.find_element_by_id(parameters['input_password_id']) + input_password.send_keys(parameters['password']) + login_button = self.driver.find_element_by_id(parameters['login_button_id']) + login_button.click() + try: + self.wait.until(EC.presence_of_element_located((By.ID, parameters['element_after_login_id']))) + except TimeoutException: + log.warning('Could not verify the presence of an element with the id "%s". Login may have failed.' % + parameters['element_after_login_id']) + self.driver.save_screenshot('/config/screenshot1.png') + log.verbose('Logged in at url: "%s". Page title is now: %s', parameters['url'], self.driver.title) + for cookie in self.driver.get_cookies(): + self.add_selenium_cookie(cookie) + # copy the obtained cookies to flexgets general requests session used by other plugins + task.requests.add_cookiejar(self.cj) + self.driver.quit() + + def imdb_login(self, task, parameters): + ''' + Use selenium to log in to imdb. Copy all session cookies to the task.requests session so that the user is logged in for the remainder of the task. + @param task: the task object of the currently running task. + @param parameters: The parameters config dict object should contain: `username`: email used for login, `password`: password used for login + ''' + self.driver.get( + 'https://www.imdb.com/registration/signin?u=https%3A//www.imdb.com/%3Fref_%3Dlgn_login&ref_=nv_generic_lgin') + login_link = self.driver.find_element_by_partial_link_text('Sign in with IMDb') + parameters['url'] = login_link.get_attribute("href") + parameters['input_username_id'] = 'ap_email' + parameters['input_password_id'] = 'ap_password' + parameters['login_button_id'] = 'signInSubmit' + parameters['element_after_login_id'] = 'nblogout' + self.login(task, parameters) + + +@event('plugin.register') +def register_plugin(): + plugin.register(Selenium, 'selenium', api_ver=2) From 13dfb3f0e2a6670326e9b2ecf5f0f75811b9383f Mon Sep 17 00:00:00 2001 From: Till Korten Date: Wed, 24 Apr 2019 16:23:27 +0200 Subject: [PATCH 2/4] removed debug code --- flexget/plugins/operate/selenium.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flexget/plugins/operate/selenium.py b/flexget/plugins/operate/selenium.py index f1255afb77..be19f812ef 100644 --- a/flexget/plugins/operate/selenium.py +++ b/flexget/plugins/operate/selenium.py @@ -256,7 +256,6 @@ def login(self, task, parameters): from selenium.webdriver.common.by import By from selenium.common.exceptions import TimeoutException self.driver.get(parameters['url']) - self.driver.save_screenshot('/config/screenshot.png') input_email = self.driver.find_element_by_id(parameters['input_username_id']) input_email.send_keys(parameters['username']) input_password = self.driver.find_element_by_id(parameters['input_password_id']) @@ -268,7 +267,6 @@ def login(self, task, parameters): except TimeoutException: log.warning('Could not verify the presence of an element with the id "%s". Login may have failed.' % parameters['element_after_login_id']) - self.driver.save_screenshot('/config/screenshot1.png') log.verbose('Logged in at url: "%s". Page title is now: %s', parameters['url'], self.driver.title) for cookie in self.driver.get_cookies(): self.add_selenium_cookie(cookie) From a0d55404e3dc88bad75e71f81b4fa799f2a19618 Mon Sep 17 00:00:00 2001 From: Till Korten Date: Wed, 24 Apr 2019 17:00:49 +0200 Subject: [PATCH 3/4] formatting improvements --- flexget/plugins/operate/selenium.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/flexget/plugins/operate/selenium.py b/flexget/plugins/operate/selenium.py index be19f812ef..9a4a653aa1 100644 --- a/flexget/plugins/operate/selenium.py +++ b/flexget/plugins/operate/selenium.py @@ -36,9 +36,19 @@ class Selenium(object): ## Configuration Currently, the following actions are supported: - 1. `cloudflare_ddos`: circumvent cloudflare protection. Parameters: 'url`: url to page that is protected by cloudflare - 2. `imdb_login`: log in to imdb. So that the rest of the task can be performed as logged in user. Parameters: `username`: email used for login, `password`: password used for login. - 3. `login`: log in to arbirtrary page. Parameters: `url`: url to login page, `username`: user name (or email) used for login, `password`: password used for login, `input_username_id`: id tag of field where username (or email) is to be entered, `input_password_id`: id tag of field where password is to be entered, `login_button_id`: id tag of button that must be clicked in order to log in, `element_after_login_id`: id of an element that appears on the page only after login, used to wait for successful loading of the page after login. + 1. `cloudflare_ddos`: circumvent cloudflare protection. Parameters: + * 'url`: url to page that is protected by cloudflare + 2. `imdb_login`: log in to imdb. So that the rest of the task can be performed as logged in user. Parameters: + * `username`: email used for login, + * `password`: password used for login. + 3. `login`: log in to arbirtrary page. Parameters: + * `url`: url to login page, + * `username`: user name (or email) used for login, + * `password`: password used for login, + * `input_username_id`: id tag of field where username (or email) is to be entered, + * `input_password_id`: id tag of field where password is to be entered, + * `login_button_id`: id tag of button that must be clicked in order to log in, + * `element_after_login_id`: id of an element that appears on the page only after login, used to wait for successful loading of the page after login. **Configuration example for cloudflare DDOS protection:** ```yaml @@ -114,7 +124,7 @@ class Selenium(object): ''' schema = { 'type': 'object', - 'properties': {'chromedriver': {'type': 'string'}, 'action': {'type': 'string'}, 'parameters': {'type': 'object'}}, + 'properties': {'action': {'type': 'string'}, 'parameters': {'type': 'object'}}, 'additionalProperties': False, } @@ -136,7 +146,7 @@ def on_task_start(self, task, config): from selenium.webdriver.support.ui import WebDriverWait from selenium.common.exceptions import WebDriverException except ImportError as e: - log.error('Error importing selenium: %s' % e) + log.error('Error importing selenium: %s', e) raise plugin.DependencyError( 'selenium', 'selenium', 'Selenium module required. run "pip install selenium". Also needs chromedriver and chromium (for example in Alpine Linux run: "apk add chromium chromium-chromedriver"). ImportError: %s' % e) self.config.update(config) @@ -171,7 +181,7 @@ def on_task_start(self, task, config): implemented_action = getattr(self, config['action']) implemented_action(task, self.config['parameters']) else: - log.debug('The selenium plugin does not support this action: %s. Allowed values for the "action" config parameter are: %s' % + log.debug('The selenium plugin does not support this action: %s. Allowed values for the "action" config parameter are: %s', config['action'], self.implemented_actions) def __del__(self): @@ -185,7 +195,7 @@ def add_selenium_cookie(self, cookie): Adds a cookie obtained by selenium to the cookiejar. @param cookie: a cookie object returned by selenium driver.get_cookie() ''' - log.debug('Added cookie: %s' % pformat(cookie)) + log.debug('Added cookie: %s', pformat(cookie)) default_attributes = ['name', 'value', 'secure', 'discard', 'comment', 'comment_url'] cookie_attributes = {'domain': None, 'domain_specified': False, 'domain_initial_dot': False, 'path': None, 'path_specified': False, 'port': None, 'port_specified': False, 'expires': None} @@ -242,7 +252,7 @@ def cloudflare_ddos(self, task, parameters): task.requests.add_cookiejar(self.cj) # update the header of flexgets requests session to match the header used to circumvent cloudflare task.requests.headers.update({'User-Agent': user_agent}) - log.debug('Cookies now stored in task.requests.cookies: %s' % pformat(task.requests.cookies)) + log.debug('Cookies now stored in task.requests.cookies: %s', pformat(task.requests.cookies)) self.driver.quit() def login(self, task, parameters): @@ -265,7 +275,7 @@ def login(self, task, parameters): try: self.wait.until(EC.presence_of_element_located((By.ID, parameters['element_after_login_id']))) except TimeoutException: - log.warning('Could not verify the presence of an element with the id "%s". Login may have failed.' % + log.warning('Could not verify the presence of an element with the id "%s". Login may have failed.', parameters['element_after_login_id']) log.verbose('Logged in at url: "%s". Page title is now: %s', parameters['url'], self.driver.title) for cookie in self.driver.get_cookies(): From 7e5c50ee86c4bd78370b0408bad1b80f6413aa9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 Oct 2021 17:04:44 +0000 Subject: [PATCH 4/4] Bump pip-tools from 6.2.0 to 6.4.0 Bumps [pip-tools](https://github.com/jazzband/pip-tools) from 6.2.0 to 6.4.0. - [Release notes](https://github.com/jazzband/pip-tools/releases) - [Changelog](https://github.com/jazzband/pip-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/jazzband/pip-tools/compare/6.2.0...6.4.0) --- updated-dependencies: - dependency-name: pip-tools dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- dev-requirements.in | 2 +- dev-requirements.txt | 62 +++++++++++++++++--------------------------- 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/dev-requirements.in b/dev-requirements.in index d954496e57..2fcccc9da2 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -13,7 +13,7 @@ pytest-runner pytest-cov==2.12.1 gitpython==3.1.18 codacy-coverage>=1.2.18 -pip-tools==6.2.0 +pip-tools==6.4.0 twine==3.4.2 isort>=5.5.1 black>=18.9b0 diff --git a/dev-requirements.txt b/dev-requirements.txt index b92baa5a54..29afe16d86 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -14,8 +14,6 @@ appdirs==1.4.4 # virtualenv astroid==2.6.6 # via pylint -atomicwrites==1.4.0 - # via pytest attrs==20.3.0 # via # -c requirements.txt @@ -36,6 +34,8 @@ certifi==2020.12.5 # via # -c requirements.txt # requests +cffi==1.15.0 + # via cryptography cfgv==3.2.0 # via pre-commit charset-normalizer==2.0.3 @@ -53,14 +53,13 @@ codacy-coverage==1.3.11 colorama==0.4.4 # via # -c requirements.txt - # pylint - # pytest - # sphinx # twine coverage==5.5 # via # -r dev-requirements.in # pytest-cov +cryptography==35.0.0 + # via secretstorage distlib==0.3.1 # via virtualenv docutils==0.16 @@ -89,25 +88,18 @@ imagesize==1.2.0 importlib-metadata==4.6.3 # via # -c requirements.txt - # flake8 # keyring - # pep517 - # pluggy - # pre-commit - # pytest # twine - # virtualenv -importlib-resources==5.2.2 - # via - # -c requirements.txt - # pre-commit - # virtualenv iniconfig==1.1.1 # via pytest isort==5.9.3 # via # -r dev-requirements.in # pylint +jeepney==0.7.1 + # via + # keyring + # secretstorage jinja2==3.0.1 # via # -c requirements.txt @@ -130,12 +122,12 @@ mccabe==0.6.1 # pylint multidict==5.1.0 # via yarl +mypy==0.812 + # via sqlalchemy-stubs mypy-extensions==0.4.3 # via # black # mypy -mypy==0.812 - # via sqlalchemy-stubs nodeenv==1.6.0 # via pre-commit packaging==20.9 @@ -147,7 +139,7 @@ pathspec==0.8.1 # via black pep517==0.10.0 # via pip-tools -pip-tools==6.2.0 +pip-tools==6.4.0 # via -r dev-requirements.in pkginfo==1.7.0 # via twine @@ -161,6 +153,8 @@ py==1.10.0 # pytest-forked pycodestyle==2.7.0 # via flake8 +pycparser==2.20 + # via cffi pyflakes==2.3.1 # via flake8 pygments==2.8.1 @@ -173,6 +167,12 @@ pyparsing==2.4.7 # via # -c requirements.txt # packaging +pytest==6.2.4 + # via + # -r dev-requirements.in + # pytest-cov + # pytest-forked + # pytest-xdist pytest-cov==2.12.1 # via -r dev-requirements.in pytest-forked==1.3.0 @@ -181,12 +181,6 @@ pytest-runner==5.3.1 # via -r dev-requirements.in pytest-xdist==2.3.0 # via -r dev-requirements.in -pytest==6.2.4 - # via - # -r dev-requirements.in - # pytest-cov - # pytest-forked - # pytest-xdist python-dateutil==2.8.2 # via # -c requirements.txt @@ -204,8 +198,6 @@ readme-renderer==29.0 # via twine regex==2021.4.4 # via black -requests-toolbelt==0.9.1 - # via twine requests==2.26.0 # via # -c requirements.txt @@ -213,10 +205,14 @@ requests==2.26.0 # requests-toolbelt # sphinx # twine +requests-toolbelt==0.9.1 + # via twine rfc3986==1.4.0 # via twine s3transfer==0.5.0 # via boto3 +secretstorage==3.3.1 + # via keyring six==1.15.0 # via # -c requirements.txt @@ -259,20 +255,12 @@ tqdm==4.60.0 twine==3.4.2 # via -r dev-requirements.in typed-ast==1.4.3 - # via - # astroid - # black - # mypy + # via mypy typing-extensions==3.10.0.0 # via # -c requirements.txt - # astroid - # black - # gitpython - # importlib-metadata # mypy # sqlalchemy-stubs - # yarl urllib3==1.26.4 # via # -c requirements.txt @@ -300,8 +288,6 @@ zipp==3.5.0 # via # -c requirements.txt # importlib-metadata - # importlib-resources - # pep517 # The following packages are considered to be unsafe in a requirements file: # pip