Repository: addisonElliott/pyqt5ac

Issue:
Introduce dependency scopes in setup.py
Modern Python modules tend to offer a setup.py that separates dependencies by usage. For example, they provide:
- `core` install (only necessary packages)
- `test` install (the packages you need to run the tests properly)
- `doc` install (the packages you need to build documentation)
- `dev` install (extra packages for development)
- aggregates install tags like `all` (install dependencies from all use-cases)
and the possibility to define extra tags in case of need (like `ci` for extra packages needed on CI only, etc.)

I understand this package has not a lot of dependencies, but I believe it's a good practice to categorize them that way. 

In addition, I'm not entirely sure what the `requirements.txt` is being used for. Currently installing this package with `pip install -e .` works just fine.

Discussion:

Patch:
diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml
index ac97bee..654c0a3 100644
--- a/.github/workflows/pythonpublish.yml
+++ b/.github/workflows/pythonpublish.yml
@@ -22,7 +22,7 @@ jobs:
       run: |
         python -m pip install --upgrade pip
         pip install setuptools wheel twine
-        pip install -r requirements.txt
+        pip install -e .
     - name: Build and publish
       env:
         TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 53f995d..c2e8fb2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -8,9 +8,7 @@ stages:
   - QA
 
 .test script: &test_script
-- pip install -e .
-- pip install PyQt5 pytest pytest-cov
-- export PYTHONPATH="$PYTHONPATH:."
+- pip install -e .[test]
 - pytest . --cov=pyqt5ac --junitxml=report.xml --cov-report html:coverage --cov-report term -vx --color=yes
 
 .test template:
diff --git a/README.md b/README.md
index 6166daa..74e73e8 100644
--- a/README.md
+++ b/README.md
@@ -52,11 +52,7 @@ a copy from GitHub and install all dependencies
 
     git clone https://github.com/addisonElliott/pyqt5ac.git
     cd pyqt5ac
-    pip install .
-
-or, for the last line, instead use
-
-    pip install -e .
+    pip install .[dev]
 
 to install in 'develop' or 'editable' mode, where changes can be made to the local working code and Python will use
 the updated code.
@@ -102,6 +98,7 @@ Whether running via the command line or from a script, the arguments and options
     * %%FILENAME%% - Filename of the source file without the extension
     * %%EXT%% - Extension excluding the period of the file (e.g. ui or qrc)
     * %%DIRNAME%% - Directory of the source file
+* **variables** - custom variables that can be used in the definition of the paths in **ioPaths**. For example, to limit the search of files to a specific directory, one can define a variable `BASEDIR` and then use it as `%%BASEDIR%%/gui/*.ui*`
 
 Example
 =======
@@ -119,10 +116,10 @@ Take the following file structure as an example project where any UI and QRC fil
 |   `-- style.qrc
 |-- modules
 |   |-- welcome
-|       |-- module.ui
-|       `-- resources
-|           |-- images
-|           `-- module.qrc
+|   |   |-- module.ui
+|   |   `-- resources
+|   |       |-- images
+|   |       `-- module.qrc
 |   `-- dataProbe
 |       |-- module.ui
 |       `-- resources
diff --git a/pyqt5ac.py b/pyqt5ac.py
index e72eeea..46974e9 100644
--- a/pyqt5ac.py
+++ b/pyqt5ac.py
@@ -125,7 +125,21 @@ def cli(rccOptions, uicOptions, force, config, iopaths=()):
     main(rccOptions, uicOptions, force, config, ioPaths)
 
 
-def main(rccOptions='', uicOptions='', force=False, config='', ioPaths=()):
+def replaceVariables(variables_definition, string_with_variables):
+    """
+    Performs variable replacements into the given string
+    :param variables_definition: mapping variable_name - variable value. Matching names encased into %% will be replaces
+    by their respective value found in the mapping (case-sensitive)
+    :param string_with_variables: String where to replace the variable names (enclosed into %%'s) with their respective
+    values found in the variables_definition
+    :return: the input string with its variables replaced.
+    """
+    for variable_name, variable_value in variables_definition.items():
+        string_with_variables = string_with_variables.replace("%%{}%%".format(variable_name), variable_value)
+    return string_with_variables
+
+
+def main(rccOptions='', uicOptions='', force=False, config='', ioPaths=(), variables=None):
     if config:
         with open(config, 'r') as fh:
             if config.endswith('.yml'):
@@ -143,11 +157,21 @@ def main(rccOptions='', uicOptions='', force=False, config='', ioPaths=()):
             uicOptions = configData.get('uic_options', uicOptions)
             force = configData.get('force', force)
             ioPaths = configData.get('ioPaths', ioPaths)
+            variables = configData.get('variables', variables)
+
+    # Validate the custom variables
+    if variables is None:
+        variables = {}
+    if 'FILENAME' in variables.keys() or 'EXT' in variables.keys() or 'DIRNAME' in variables.keys():
+        raise ValueError("Custom variables cannot be called FILENAME, EXT or DIRNAME.")
 
     # Loop through the list of io paths
     for sourceFileExpr, destFileExpr in ioPaths:
         foundItem = False
 
+        # Replace instances of the variables with the actual values of the available variables
+        sourceFileExpr = replaceVariables(variables, sourceFileExpr)
+
         # Find files that match the source filename expression given
         for sourceFilename in glob.glob(sourceFileExpr, recursive=True):
             # If the filename does not exist, not sure why this would ever occur, but show a warning
@@ -169,9 +193,8 @@ def main(rccOptions='', uicOptions='', force=False, config='', ioPaths=()):
             filename, ext = os.path.splitext(basename)
 
             # Replace instances of the variables with the actual values from the source filename
-            destFilename = destFileExpr.replace('%%FILENAME%%', filename) \
-                .replace('%%EXT%%', ext[1:]) \
-                .replace('%%DIRNAME%%', dirname)
+            variables.update({'FILENAME': filename, 'EXT': ext[1:], 'DIRNAME': dirname})
+            destFilename = replaceVariables(variables, destFileExpr)
 
             # Retrieve the absolute path to the source and destination filename
             sourceFilename, destFilename = os.path.abspath(sourceFilename), os.path.abspath(destFilename)
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 6053f4a..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-click
-pyyaml
\ No newline at end of file
diff --git a/setup.py b/setup.py
index f4f6ee9..093956e 100644
--- a/setup.py
+++ b/setup.py
@@ -25,6 +25,24 @@ with open(os.path.join(currentPath, 'README.md'), 'r') as f:
 
 longDescription = '\n' + longDescription
 
+REQUIREMENTS = {
+    'core': [
+        'PyQt5',
+        'click',
+        'pyyaml',
+    ],
+    'test': [
+        'pytest',
+        'pytest-cov',
+    ],
+    'dev': [
+        # 'requirement-for-development-purposes-only',
+    ],
+    'doc': [
+    ],
+}
+
+
 setup(name='pyqt5ac',
       version=find_version('pyqt5ac.py'),
       description='Python module to automatically compile UI and RC files in PyQt5 to Python files',
@@ -34,7 +52,17 @@ setup(name='pyqt5ac',
       author_email='addison.elliott@gmail.com',
       url='https://github.com/addisonElliott/pyqt5ac',
       license='MIT License',
-      install_requires=['click', 'pyyaml'],
+      install_requires=REQUIREMENTS['core'],
+      extras_require={
+        **REQUIREMENTS,
+        # The 'dev' extra is the union of 'test' and 'doc', with an option
+        # to have explicit development dependencies listed.
+        'dev': [req
+                for extra in ['dev', 'test', 'doc']
+                for req in REQUIREMENTS.get(extra, [])],
+        # The 'all' extra is the union of all requirements.
+        'all': [req for reqs in REQUIREMENTS.values() for req in reqs],
+      },
       python_requires='>=3',
       py_modules=['pyqt5ac'],
       entry_points={
@@ -55,4 +83,4 @@ setup(name='pyqt5ac',
           'Source': 'https://github.com/addisonElliott/pyqt5ac',
           'Tracker': 'https://github.com/addisonElliott/pyqt5ac/issues',
       }
-      )
+)


Test patch:
diff --git a/.github/workflows/test_on_push.yml b/.github/workflows/test_on_push.yml
index 34387bd..2d1814c 100644
--- a/.github/workflows/test_on_push.yml
+++ b/.github/workflows/test_on_push.yml
@@ -16,8 +16,7 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install -e .
-        pip install PyQt5 pytest pytest-cov
+        pip install -e .[test]
     - name: Test with pytest
       run: |
-        pytest . --cov=pyqt5ac --junitxml=report.xml --cov-report html:coverage --cov-report term
+        pytest . --cov=pyqt5ac --junitxml=report.xml --cov-report html:coverage --cov-report term -vx --color=yes
diff --git a/tests/test_pyqt5ac.py b/tests/test_pyqt5ac.py
index 99619ee..1df5e0e 100644
--- a/tests/test_pyqt5ac.py
+++ b/tests/test_pyqt5ac.py
@@ -1,6 +1,8 @@
 import os
 import time
 
+import pytest
+
 from .. import pyqt5ac
 
 
@@ -32,6 +34,22 @@ def _write_config_file(dir):
     return config
 
 
+def _write_config_file_with_variables(dir, variable_name, variable_value):
+    config = dir.join("input_config.yml")
+    config.write("""variables:
+  {variable_name}: {variable_value}
+ioPaths:
+  -
+    - '%%{variable_name}%%/gui/*.ui'
+    - '%%{variable_name}%%/generated/%%FILENAME%%_ui.py'
+  -
+    - '%%{variable_name}%%/resources/*.qrc'
+    - '%%{variable_name}%%/generated/%%FILENAME%%_rc.py'""".format(variable_name=variable_name,
+                                                                   variable_value=variable_value))
+
+    return config
+
+
 def _write_ui_file(file):
     file.write("""<?xml version="1.0" encoding="UTF-8"?>
     <ui version="4.0">
@@ -204,6 +222,25 @@ def test_resource_generation_when_image_out_of_date(tmpdir):
     assert "test" != dest_file.read()
 
 
+def test_generation_fails_with_forbidden_filename_variable(tmpdir):
+    run_forbidden_variable_case(tmpdir, 'FILENAME')
+
+
+def test_generation_fails_with_forbidden_ext_variable(tmpdir):
+    run_forbidden_variable_case(tmpdir, 'EXT')
+
+
+def test_generation_fails_with_forbidden_dirname_variable(tmpdir):
+    run_forbidden_variable_case(tmpdir, 'DIRNAME')
+
+
+def run_forbidden_variable_case(tmpdir, variable):
+    config = _write_config_file_with_variables(tmpdir, variable, 'value')
+
+    with pytest.raises(ValueError):
+        pyqt5ac.main(config=str(config))
+
+
 def test_ui_generation_when_invalid(tmpdir):
     config = _write_config_file(tmpdir)
     ui_file = tmpdir.mkdir("gui").join("main.ui")
@@ -214,3 +251,14 @@ def test_ui_generation_when_invalid(tmpdir):
     assert tmpdir.join("generated").check()
     # TODO generated file should not exist and pyqt5ac should fail
     assert tmpdir.join("generated/main_ui.py").check()
+
+
+def test_ui_generation_with_variables(tmpdir):
+    config = _write_config_file_with_variables(tmpdir, 'BASENAME', str(tmpdir))
+    ui_file = tmpdir.mkdir("gui").join("main.ui")
+    _write_ui_file(ui_file)
+
+    pyqt5ac.main(config=str(config), uicOptions="-d")
+
+    _assert_path_exists(tmpdir.join("generated"))
+    _assert_path_exists(tmpdir.join("generated/main_ui.py"))