Repository: qutebrowser/qutebrowser

Issue:
Remove optional cssutils dependency
qutebrowser depends on [cssutils](https://cthedot.de/cssutils/) - after being on life support for a while (thanks to @Kingdread for continuing to maintain it during that time), it looks like its repository is now gone after Bitbucket removed Mercurial support: https://bitbucket.org/cthedot/cssutils/

It's only needed for better MHTML download support on QtWebKit, which is something very niche, so I doubt anyone will care if we remove support for it. Thus, we should remove it.

This could be a good first contribution if someone wants to take it - essentially, search for `cssutils` in the repository and remove everything related to it (code, tests, documentation, requirements, etc.)


Discussion:
Hi @The-Compiler, I would like to contribute to this issue.
Yay, please go ahead!

Something I didn't consider before: I'm not sure how well you can test those changes locally. Running the relevant tests in `tests/unit/browser/webkit/test_mhtml.py` will require having QtWebKit installed, which can be a bit of a problem depending on your system - what OS/distribution are you using?

FWIW it's still a possibility to do those changes "blindly" though, and run the tests you can locally, or keep an eye on GitHub Actions where they'll all run - I can then finish the rest if you get stuck.
I use ubuntu 18.04. I can try to setup QtWebKit and run ```tests/unit/browser/webkit/test_mhtml.py``` tests. I can let you know if I face any problem.


Resolving patch:
diff --git a/qutebrowser/browser/webkit/mhtml.py b/qutebrowser/browser/webkit/mhtml.py
--- a/qutebrowser/browser/webkit/mhtml.py
+++ b/qutebrowser/browser/webkit/mhtml.py
@@ -62,7 +62,7 @@ class _File:
 ]]
 
 
-def _get_css_imports_regex(data):
+def _get_css_imports(data):
     """Return all assets that are referenced in the given CSS document.
 
     The returned URLs are relative to the stylesheet's URL.
@@ -79,55 +79,6 @@ def _get_css_imports_regex(data):
     return urls
 
 
-def _get_css_imports_cssutils(data, inline=False):
-    """Return all assets that are referenced in the given CSS document.
-
-    The returned URLs are relative to the stylesheet's URL.
-
-    Args:
-        data: The content of the stylesheet to scan as string.
-        inline: True if the argument is an inline HTML style attribute.
-    """
-    try:
-        import cssutils
-    except ImportError:
-        return None
-
-    # We don't care about invalid CSS data, this will only litter the log
-    # output with CSS errors
-    parser = cssutils.CSSParser(loglevel=100,
-                                fetcher=lambda url: (None, ""), validate=False)
-    if not inline:
-        sheet = parser.parseString(data)
-        return list(cssutils.getUrls(sheet))
-    else:
-        urls = []
-        declaration = parser.parseStyle(data)
-        # prop = background, color, margin, ...
-        for prop in declaration:
-            # value = red, 10px, url(foobar), ...
-            for value in prop.propertyValue:
-                if isinstance(value, cssutils.css.URIValue):
-                    if value.uri:
-                        urls.append(value.uri)
-        return urls
-
-
-def _get_css_imports(data, inline=False):
-    """Return all assets that are referenced in the given CSS document.
-
-    The returned URLs are relative to the stylesheet's URL.
-
-    Args:
-        data: The content of the stylesheet to scan as string.
-        inline: True if the argument is an inline HTML style attribute.
-    """
-    imports = _get_css_imports_cssutils(data, inline)
-    if imports is None:
-        imports = _get_css_imports_regex(data)
-    return imports
-
-
 def _check_rel(element):
     """Return true if the element's rel attribute fits our criteria.
 
@@ -328,7 +279,7 @@ def run(self):
         for element in web_frame.findAllElements('[style]'):
             element = webkitelem.WebKitElement(element, tab=self.tab)
             style = element['style']
-            for element_url in _get_css_imports(style, inline=True):
+            for element_url in _get_css_imports(style):
                 self._fetch_url(web_url.resolved(QUrl(element_url)))
 
         # Shortcut if no assets need to be downloaded, otherwise the file would
diff --git a/qutebrowser/utils/version.py b/qutebrowser/utils/version.py
--- a/qutebrowser/utils/version.py
+++ b/qutebrowser/utils/version.py
@@ -263,7 +263,6 @@ def _module_versions() -> Sequence[str]:
         ('jinja2', ['__version__']),
         ('pygments', ['__version__']),
         ('yaml', ['__version__']),
-        ('cssutils', ['__version__']),
         ('attr', ['__version__']),
         ('PyQt5.QtWebEngineWidgets', []),
         ('PyQt5.QtWebEngine', ['PYQT_WEBENGINE_VERSION_STR']),
diff --git a/scripts/dev/recompile_requirements.py b/scripts/dev/recompile_requirements.py
--- a/scripts/dev/recompile_requirements.py
+++ b/scripts/dev/recompile_requirements.py
@@ -164,7 +164,6 @@
     'yamllint': 'https://github.com/adrienverge/yamllint/blob/master/CHANGELOG.rst',
     'pathspec': 'https://github.com/cpburnz/python-path-specification/blob/master/CHANGES.rst',
     'filelock': 'https://github.com/benediktschmitt/py-filelock/commits/master',
-    'cssutils': 'https://pythonhosted.org/cssutils/CHANGELOG.html',
     'github3.py': 'https://github3py.readthedocs.io/en/master/release-notes/index.html',
     'manhole': 'https://github3py.readthedocs.io/en/master/release-notes/index.html',
     'pycparser': 'https://github.com/eliben/pycparser/blob/master/CHANGES',


Test patch:
diff --git a/tests/unit/browser/webkit/test_mhtml.py b/tests/unit/browser/webkit/test_mhtml.py
--- a/tests/unit/browser/webkit/test_mhtml.py
+++ b/tests/unit/browser/webkit/test_mhtml.py
@@ -28,12 +28,6 @@
 mhtml = pytest.importorskip('qutebrowser.browser.webkit.mhtml')
 
 
-try:
-    import cssutils
-except ImportError:
-    cssutils = None
-
-
 @pytest.fixture(autouse=True)
 def patch_uuid(monkeypatch):
     monkeypatch.setattr(uuid, "uuid4", lambda: "UUID")
@@ -255,34 +249,25 @@ def test_empty_content_type(checker):
         """)
 
 
-@pytest.mark.parametrize('has_cssutils', [
-    pytest.param(True, marks=pytest.mark.skipif(
-        cssutils is None, reason="requires cssutils"), id='with_cssutils'),
-    pytest.param(False, id='no_cssutils'),
-])
-@pytest.mark.parametrize('inline, style, expected_urls', [
-    pytest.param(False, "@import 'default.css'", ['default.css'],
+@pytest.mark.parametrize('style, expected_urls', [
+    pytest.param("@import 'default.css'", ['default.css'],
                  id='import with apostrophe'),
-    pytest.param(False, '@import "default.css"', ['default.css'],
+    pytest.param('@import "default.css"', ['default.css'],
                  id='import with quote'),
-    pytest.param(False, "@import \t 'tabbed.css'", ['tabbed.css'],
+    pytest.param("@import \t 'tabbed.css'", ['tabbed.css'],
                  id='import with tab'),
-    pytest.param(False, "@import url('default.css')", ['default.css'],
+    pytest.param("@import url('default.css')", ['default.css'],
                  id='import with url()'),
-    pytest.param(False, """body {
+    pytest.param("""body {
     background: url("/bg-img.png")
     }""", ['/bg-img.png'], id='background with body'),
-    pytest.param(True, 'background: url(folder/file.png) no-repeat',
+    pytest.param('background: url(folder/file.png) no-repeat',
                  ['folder/file.png'], id='background'),
-    pytest.param(True, 'content: url()', [], id='content'),
+    pytest.param('content: url()', [], id='content'),
 ])
-def test_css_url_scanner(monkeypatch, has_cssutils, inline, style,
-                         expected_urls):
-    if not has_cssutils:
-        monkeypatch.setattr(mhtml, '_get_css_imports_cssutils',
-                            lambda data, inline=False: None)
+def test_css_url_scanner(monkeypatch, style, expected_urls):
     expected_urls.sort()
-    urls = mhtml._get_css_imports(style, inline=inline)
+    urls = mhtml._get_css_imports(style)
     urls.sort()
     assert urls == expected_urls
 
diff --git a/tests/unit/utils/test_version.py b/tests/unit/utils/test_version.py
--- a/tests/unit/utils/test_version.py
+++ b/tests/unit/utils/test_version.py
@@ -561,7 +561,6 @@ def __init__(self):
             ('jinja2', True),
             ('pygments', True),
             ('yaml', True),
-            ('cssutils', True),
             ('attr', True),
             ('PyQt5.QtWebEngineWidgets', True),
             ('PyQt5.QtWebEngine', True),
@@ -637,7 +636,6 @@ def test_all_present(self, import_fake):
 
     @pytest.mark.parametrize('module, idx, expected', [
         ('colorama', 1, 'colorama: no'),
-        ('cssutils', 6, 'cssutils: no'),
     ])
     def test_missing_module(self, module, idx, expected, import_fake):
         """Test with a module missing.
@@ -681,7 +679,6 @@ def test_version_attribute(self, attribute, expected_modules, import_fake):
         ('jinja2', True),
         ('pygments', True),
         ('yaml', True),
-        ('cssutils', True),
         ('attr', True),
     ])
     def test_existing_attributes(self, name, has_version):
@@ -694,8 +691,6 @@ def test_existing_attributes(self, name, has_version):
             name: The name of the module to check.
             has_version: Whether a __version__ attribute is expected.
         """
-        if name == 'cssutils':
-            pytest.importorskip(name)
         module = importlib.import_module(name)
         assert hasattr(module, '__version__') == has_version