Skip to content

Commit 02156b7

Browse files
authored
Add support for ignored_dependency_scopes field for configuration (#1235)
Signed-off-by: tdruez <tdruez@nexb.com>
1 parent aba9331 commit 02156b7

11 files changed

Lines changed: 318 additions & 12 deletions

File tree

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ v34.5.0 (unreleased)
6060
- Remove the ``extract_recursively`` option from the Project configuration.
6161
https://github.com/nexB/scancode.io/issues/1236
6262

63+
- Add support for a ``ignored_dependency_scopes`` field on the Project configuration.
64+
https://github.com/nexB/scancode.io/issues/1197
65+
6366
- Add support for storing the scancode-config.yml file in codebase.
6467
The scancode-config.yml file can be provided as a project input, or can be located
6568
in the codebase/ immediate subdirectories. This allows to provide the configuration

docs/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@
6767
# a list of builtin themes.
6868
html_theme = "sphinx_rtd_theme"
6969

70+
# The style name to use for Pygments highlighting of source code.
71+
pygments_style = "emacs"
72+
7073
# Add any paths that contain custom static files (such as style sheets) here,
7174
# relative to this directory. They are copied after the builtin static files,
7275
# so a file named "default.css" will overwrite the builtin "default.css".

docs/project-configuration.rst

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ Content of a ``scancode-config.yml`` file:
4040
product_version: '1.0'
4141
ignored_patterns:
4242
- '*.tmp'
43-
- tests/*
43+
- 'tests/*'
44+
ignored_dependency_scopes:
45+
- package_type: npm
46+
scope: devDependencies
47+
- package_type: pypi
48+
scope: tests
4449
4550
See the :ref:`project_configuration_settings` section for the details about each
4651
setting.
@@ -49,7 +54,6 @@ setting.
4954
You can generate the project configuration file from the
5055
:ref:`user_interface_project_settings` UI.
5156

52-
5357
.. _project_configuration_settings:
5458

5559
Settings
@@ -86,3 +90,41 @@ within the project.
8690

8791
.. warning::
8892
Be cautious when specifying patterns to avoid unintended exclusions.
93+
94+
ignored_dependency_scopes
95+
^^^^^^^^^^^^^^^^^^^^^^^^^
96+
97+
Specify certain dependency scopes to be ignored for a given package type.
98+
This allows you to exclude dependencies from being created or resolved based on their
99+
scope.
100+
101+
**Guidelines:**
102+
103+
- **Exact Matches Only:** The scope names must be specified exactly as they appear.
104+
Wildcards and partial matches are not supported.
105+
- **Scope Specification:** List each scope name you wish to ignore.
106+
107+
**Examples:**
108+
109+
To exclude all ``devDependencies`` for ``npm`` packages and ``tests`` for ``pypi``
110+
packages, define the following in your ``scancode-config.yml`` configuration file:
111+
112+
.. code-block:: yaml
113+
114+
ignored_dependency_scopes:
115+
- package_type: npm
116+
scope: devDependencies
117+
- package_type: pypi
118+
scope: tests
119+
120+
If you prefer to use the :ref:`user_interface_project_settings` form, list each
121+
ignored scope using the `package_type:scope` syntax, **one per line**, such as:
122+
123+
.. code-block:: text
124+
125+
npm:devDependencies
126+
pypi:tests
127+
128+
.. warning::
129+
Be precise when listing scope names to avoid unintended exclusions.
130+
Ensure the scope names are correct and reflect your project requirements.

scanpipe/forms.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,68 @@ def prepare_value(self, value):
276276
return value
277277

278278

279-
ignored_patterns_help_markdown = """
279+
class KeyValueListField(forms.CharField):
280+
"""
281+
A Django form field that displays as a textarea and converts each line of
282+
"key:value" input into a list of dictionaries with customizable keys.
283+
284+
Each line of the textarea input is split into key-value pairs,
285+
removing leading/trailing whitespace and empty lines. The resulting list of
286+
dictionaries is then stored as the field value.
287+
"""
288+
289+
widget = forms.Textarea
290+
291+
def __init__(self, *args, key_name="key", value_name="value", **kwargs):
292+
"""Initialize the KeyValueListField with custom key and value names."""
293+
self.key_name = key_name
294+
self.value_name = value_name
295+
super().__init__(*args, **kwargs)
296+
297+
def to_python(self, value):
298+
"""
299+
Split the textarea input into lines, convert each line to a dictionary,
300+
and remove empty lines.
301+
"""
302+
if not value:
303+
return None
304+
305+
items = []
306+
for line in value.splitlines():
307+
line = line.strip()
308+
if not line:
309+
continue
310+
parts = line.split(":", 1)
311+
if len(parts) != 2:
312+
raise ValidationError(
313+
f"Invalid input line: '{line}'. "
314+
f"Each line must contain exactly one ':' character."
315+
)
316+
key, value = parts
317+
key = key.strip()
318+
value = value.strip()
319+
if not key or not value:
320+
raise ValidationError(
321+
f"Invalid input line: '{line}'. "
322+
f"Both key and value must be non-empty."
323+
)
324+
items.append({self.key_name: key, self.value_name: value})
325+
326+
return items
327+
328+
def prepare_value(self, value):
329+
"""
330+
Join the list of dictionaries into a string with newlines,
331+
using the "key:value" format.
332+
"""
333+
if value is not None and isinstance(value, list):
334+
value = "\n".join(
335+
f"{item[self.key_name]}:{item[self.value_name]}" for item in value
336+
)
337+
return value
338+
339+
340+
ignored_patterns_help = """
280341
Provide one or more path patterns to be ignored, one per line.
281342
282343
Each pattern should follow the syntax of Unix shell-style wildcards:
@@ -295,18 +356,27 @@ def prepare_value(self, value):
295356
Be cautious when specifying patterns to avoid unintended exclusions.
296357
"""
297358

359+
ignored_dependency_scopes_help = """
360+
Specify certain dependency scopes to be ignored for a given package type.
361+
362+
This allows you to exclude dependencies from being created or resolved based on their
363+
scope using the `package_type:scope` syntax, **one per line**.
364+
For example: `npm:devDependencies`
365+
"""
366+
298367

299368
class ProjectSettingsForm(forms.ModelForm):
300369
settings_fields = [
301370
"ignored_patterns",
371+
"ignored_dependency_scopes",
302372
"attribution_template",
303373
"product_name",
304374
"product_version",
305375
]
306376
ignored_patterns = ListTextarea(
307377
label="Ignored patterns",
308378
required=False,
309-
help_text=convert_markdown_to_html(ignored_patterns_help_markdown.strip()),
379+
help_text=convert_markdown_to_html(ignored_patterns_help.strip()),
310380
widget=forms.Textarea(
311381
attrs={
312382
"class": "textarea is-dynamic",
@@ -315,6 +385,20 @@ class ProjectSettingsForm(forms.ModelForm):
315385
},
316386
),
317387
)
388+
ignored_dependency_scopes = KeyValueListField(
389+
label="Ignored dependency scopes",
390+
required=False,
391+
help_text=convert_markdown_to_html(ignored_dependency_scopes_help.strip()),
392+
widget=forms.Textarea(
393+
attrs={
394+
"class": "textarea is-dynamic",
395+
"rows": 2,
396+
"placeholder": "npm:devDependencies\npypi:tests",
397+
},
398+
),
399+
key_name="package_type",
400+
value_name="scope",
401+
)
318402
attribution_template = forms.CharField(
319403
label="Attribution template",
320404
required=False,

scanpipe/models.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import shutil
2828
import uuid
2929
from collections import Counter
30+
from collections import defaultdict
3031
from contextlib import suppress
3132
from itertools import groupby
3233
from operator import itemgetter
@@ -759,7 +760,7 @@ def get_input_config_file(self):
759760
Priority order:
760761
1. If a config file exists directly in the input/ directory, return it.
761762
2. If exactly one config file exists in a codebase/ immediate subdirectory,
762-
return it.
763+
return it.
763764
3. If multiple config files are found in subdirectories, report an error.
764765
"""
765766
config_filename = settings.SCANCODEIO_CONFIG_FILE
@@ -830,6 +831,29 @@ def get_env(self, field_name=None):
830831

831832
return env
832833

834+
def get_ignored_dependency_scopes_index(self):
835+
"""
836+
Return a dictionary index of the ``ignored_dependency_scopes`` setting values
837+
defined in this Project env.
838+
"""
839+
ignored_dependency_scopes = self.get_env(field_name="ignored_dependency_scopes")
840+
if not ignored_dependency_scopes:
841+
return {}
842+
843+
ignored_scope_index = defaultdict(list)
844+
for entry in ignored_dependency_scopes:
845+
ignored_scope_index[entry.get("package_type")].append(entry.get("scope"))
846+
847+
return dict(ignored_scope_index)
848+
849+
@cached_property
850+
def ignored_dependency_scopes_index(self):
851+
"""
852+
Return the computed value of get_ignored_dependency_scopes_index.
853+
The value is only generated once and cached for further calls.
854+
"""
855+
return self.get_ignored_dependency_scopes_index()
856+
833857
def clear_tmp_directory(self):
834858
"""
835859
Delete the whole content of the tmp/ directory.

scanpipe/pipes/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,25 @@ def create_local_files_package(project, defaults, codebase_resources=None):
216216
return update_or_create_package(project, package_data, codebase_resources)
217217

218218

219+
def ignore_dependency_scope(project, dependency_data):
220+
"""
221+
Return True if the dependency should be ignored, i.e.: not created.
222+
The ignored scopes are defined on the project ``ignored_dependency_scopes`` setting
223+
field.
224+
"""
225+
ignored_scope_index = project.ignored_dependency_scopes_index
226+
if not ignored_scope_index:
227+
return False
228+
229+
dependency_package_type = dependency_data.get("package_type")
230+
dependency_scope = dependency_data.get("scope")
231+
if dependency_package_type and dependency_scope:
232+
if dependency_scope in ignored_scope_index.get(dependency_package_type, []):
233+
return True # Ignore this dependency entry.
234+
235+
return False
236+
237+
219238
def update_or_create_dependency(
220239
project,
221240
dependency_data,
@@ -239,6 +258,9 @@ def update_or_create_dependency(
239258
dependency = None
240259
dependency_uid = dependency_data.get("dependency_uid")
241260

261+
if ignore_dependency_scope(project, dependency_data):
262+
return # Do not create the DiscoveredDependency record.
263+
242264
if not dependency_uid:
243265
dependency_data["dependency_uid"] = uuid.uuid4()
244266
else:

scanpipe/templates/scanpipe/project_settings.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@
6464
{{ form.ignored_patterns.help_text|safe|linebreaksbr }}
6565
</div>
6666
</div>
67+
68+
<div class="field">
69+
<label class="label" for="{{ form.ignored_dependency_scopes.id_for_label }}">
70+
{{ form.ignored_dependency_scopes.label }}
71+
</label>
72+
<div class="control">
73+
{{ form.ignored_dependency_scopes }}
74+
</div>
75+
<div class="help">
76+
{{ form.ignored_dependency_scopes.help_text|safe|linebreaksbr }}
77+
</div>
78+
</div>
6779
</div>
6880
</div>
6981

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1+
product_name: My Product Name
2+
product_version: '1.0'
13
ignored_patterns:
2-
- "*.img"
3-
- "docs/*"
4-
- "*/tests/*"
4+
- '*.tmp'
5+
- 'tests/*'
6+
ignored_dependency_scopes:
7+
- package_type: npm
8+
scope: devDependencies
9+
- package_type: pypi
10+
scope: tests

scanpipe/tests/pipes/test_pipes.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,30 @@ def test_scanpipe_pipes_update_or_create_dependency(self):
192192
dependency = pipes.update_or_create_dependency(p1, dependency_data)
193193
self.assertEqual("install", dependency.scope)
194194

195+
def test_scanpipe_pipes_update_or_create_dependency_ignored_dependency_scopes(self):
196+
p1 = Project.objects.create(name="Analysis")
197+
make_resource_file(p1, "daglib-0.3.2.tar.gz-extract/daglib-0.3.2/PKG-INFO")
198+
pipes.update_or_create_package(p1, package_data1)
199+
200+
p1.settings = {
201+
"ignored_dependency_scopes": [{"package_type": "pypi", "scope": "tests"}]
202+
}
203+
p1.save()
204+
205+
dependency_data = dict(dependency_data1)
206+
self.assertFalse(pipes.ignore_dependency_scope(p1, dependency_data))
207+
dependency = pipes.update_or_create_dependency(p1, dependency_data)
208+
for field_name, value in dependency_data.items():
209+
self.assertEqual(value, getattr(dependency, field_name), msg=field_name)
210+
dependency.delete()
211+
212+
# Matching the ignored setting
213+
dependency_data["package_type"] = "pypi"
214+
dependency_data["scope"] = "tests"
215+
self.assertTrue(pipes.ignore_dependency_scope(p1, dependency_data))
216+
dependency = pipes.update_or_create_dependency(p1, dependency_data)
217+
self.assertIsNone(dependency)
218+
195219
def test_scanpipe_pipes_get_or_create_relation(self):
196220
p1 = Project.objects.create(name="Analysis")
197221
from1 = make_resource_file(p1, "from/a.txt")

0 commit comments

Comments
 (0)