Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: ignore-from-file relative to config file #701

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

jellehelsen
Copy link

This fixes the problem (#673) that files mentioned in ignore-from-file can not be found when running in a subdirectory. Values in ignore-from-file are now treated as being relative to the config file.

@coveralls
Copy link

coveralls commented Nov 26, 2024

Coverage Status

coverage: 99.827% (+0.002%) from 99.825%
when pulling 174a64d on jellehelsen:fix/relative-ignore-from-file
into e118296 on adrienverge:master.

@adrienverge
Copy link
Owner

Hello!

Depending on the outcome of #673 and what @ndrwnaguib thinks, this PR could be welcome and merged 👍

I haven't reviewed yet, but I can already ask:

  • Can you please minimize all the diff that can be? E.g. don't change config to c; keep validate_rule_conf() independant (not in a class) and pass it config_dir if needed; etc.
  • Make the commit clean and ready to review. For example I saw a self.maxDiff = None.
  • Document this new behavior by updating documentation, for example inside a new note below this one: https://github.com/adrienverge/yamllint/blob/8513d9b/docs/configuration.rst?plain=1#L229
  • Please keep single quotes 🙏
  • Add test cases for at least:
      - an ignore-from-file: file when file is in the root dir but current dir is a sub dir (should find it)
      - an ignore-from-file: file when file is in a sub dir but current dir is this sub dir (should error)
      - an ignore-from-file: file when configuration is not passed via a file (found)
      - an ignore-from-file: file when configuration is not passed via a file (error)
      - other cases?

@jellehelsen jellehelsen force-pushed the fix/relative-ignore-from-file branch 4 times, most recently from 257daf9 to bb68cd0 Compare December 18, 2024 15:31
@jellehelsen jellehelsen force-pushed the fix/relative-ignore-from-file branch from bb68cd0 to 174a64d Compare December 18, 2024 15:36
@jellehelsen
Copy link
Author

Hi,

I think I've addressed all your remarks.

  • validate_rule_conf() is back out of the class and get passed the config_dir with None as default value, restored corresponding test
  • Tests added and existing tests cleaned.
  • Quotes restored (I should not have run ruff format. Apologies)
  • Added a line of documentation at the suggested location to indicate that the ignore-from-file path is relative to the config file.

Copy link
Owner

@adrienverge adrienverge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello Jelle,

Thanks for the update!

I read how Git handles .gitignore files (yamllint takes inspiration from it) and the documentation is clear: "all paths are relative from the .gitignore file". This means:
- the purpose of this PR is right, changing current behavior makes sense ✅
- the paths of files to ignore should be interpreted relatively to the ignore file (e.g. .gitignore),
- the path of the ignore file (e.g. .gitignore) path should be interpreted *relatively to the config file (.yamllint).
In most cases, the config file (.yamllint) and the ignore file (.gitignore) are in the same directory. But in theory they can be in different places, so the pull request should be updated to reflect that.
What do you think?

Your 2 first test functions make sense and can be left where they are. But I adapted the 2 others and wrote new ones to achieve the described behavior ↑ (these ones should be appended at the end of the test file, it's more logical).
Can you check these and tell if you agree?

    def test_run_with_ignore_file_in_subdir(self):
        # .                               ← yamllint is run from here
        # ├── .yamllint
        # └── bin
        #     ├── .gitignore              ← correctly references other files
        #     └── .ignore-key-duplicates
        with open(os.path.join(self.wd, '.yamllint'), 'w') as f:
            f.write('extends: default\n'
                    'ignore-from-file: bin/.gitignore\n'
                    'rules:\n'
                    '  key-duplicates:\n'
                    '    ignore-from-file: bin/.ignore-key-duplicates\n')

        with open(os.path.join(self.wd, 'bin', '.gitignore'), 'w') as f:
            f.write('*.dont-lint-me.yaml\n'
                    '/\n'
                    '!/*.lint-me-anyway.yaml\n')

        with open(os.path.join(self.wd, 'bin',
                               '.ignore-key-duplicates'), 'w') as f:
            f.write('/../ign-dup\n')

        sys.stdout = StringIO()
        with self.assertRaises(SystemExit):
            cli.run(('-f', 'parsable', '.'))

        os.remove(os.path.join(self.wd, 'bin', '.gitignore'))
        os.remove(os.path.join(self.wd, 'bin', '.ignore-key-duplicates'))

        out = sys.stdout.getvalue()
        out = '\n'.join(sorted(out.splitlines()))

        docstart = '[warning] missing document start "---" (document-start)'
        keydup = '[error] duplication of key "key" in mapping (key-duplicates)'
        trailing = '[error] trailing spaces (trailing-spaces)'
        hyphen = '[error] too many spaces after hyphen (hyphens)'

        self.assertEqual(out, '\n'.join((
            './.yamllint:1:1: ' + docstart,
            './bin/file.lint-me-anyway.yaml:3:3: ' + keydup,
            './bin/file.lint-me-anyway.yaml:4:17: ' + trailing,
            './bin/file.lint-me-anyway.yaml:5:5: ' + hyphen,
            './file-at-root.yaml:3:3: ' + keydup,
            './file-at-root.yaml:4:17: ' + trailing,
            './file-at-root.yaml:5:5: ' + hyphen,
            './ign-dup/file.yaml:4:17: ' + trailing,
            './ign-dup/file.yaml:5:5: ' + hyphen,
            './ign-dup/sub/dir/file.yaml:4:17: ' + trailing,
            './ign-dup/sub/dir/file.yaml:5:5: ' + hyphen,
            './ign-trail/file.yaml:3:3: ' + keydup,
            './ign-trail/file.yaml:4:17: ' + trailing,
            './ign-trail/file.yaml:5:5: ' + hyphen,
            './include/ign-dup/sub/dir/file.yaml:3:3: ' + keydup,
            './include/ign-dup/sub/dir/file.yaml:4:17: ' + trailing,
            './include/ign-dup/sub/dir/file.yaml:5:5: ' + hyphen,
            './s/s/ign-trail/file.yaml:3:3: ' + keydup,
            './s/s/ign-trail/file.yaml:4:17: ' + trailing,
            './s/s/ign-trail/file.yaml:5:5: ' + hyphen,
            './s/s/ign-trail/s/s/file.yaml:3:3: ' + keydup,
            './s/s/ign-trail/s/s/file.yaml:4:17: ' + trailing,
            './s/s/ign-trail/s/s/file.yaml:5:5: ' + hyphen,
            './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:3:3: ' + keydup,
            './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:4:17: ' + trailing,
            './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:5:5: ' + hyphen,
        )))

    def test_run_with_broken_ignore_file_in_subdir(self):
        # .                               ← yamllint is run from here
        # ├── .yamllint
        # └── bin
        #     ├── .gitignore              ← incorrectly references other files
        #     └── .ignore-key-duplicates
        with open(os.path.join(self.wd, '.yamllint'), 'w') as f:
            f.write('extends: default\n'
                    'ignore-from-file: bin/.gitignore\n'
                    'rules:\n'
                    '  key-duplicates:\n'
                    '    ignore-from-file: bin/.ignore-key-duplicates\n')

        with open(os.path.join(self.wd, 'bin', '.gitignore'), 'w') as f:
            f.write('*.dont-lint-me.yaml\n'
                    '/bin/\n'
                    '!/bin/*.lint-me-anyway.yaml\n')

        with open(os.path.join(self.wd, 'bin',
                               '.ignore-key-duplicates'), 'w') as f:
            f.write('/ign-dup\n')

        sys.stdout = StringIO()
        with self.assertRaises(SystemExit):
            cli.run(('-f', 'parsable', '.'))

        os.remove(os.path.join(self.wd, 'bin', '.gitignore'))
        os.remove(os.path.join(self.wd, 'bin', '.ignore-key-duplicates'))

        out = sys.stdout.getvalue()
        out = '\n'.join(sorted(out.splitlines()))

        docstart = '[warning] missing document start "---" (document-start)'
        keydup = '[error] duplication of key "key" in mapping (key-duplicates)'
        trailing = '[error] trailing spaces (trailing-spaces)'
        hyphen = '[error] too many spaces after hyphen (hyphens)'

        self.assertEqual(out, '\n'.join((
            './.yamllint:1:1: ' + docstart,
            './bin/file.lint-me-anyway.yaml:3:3: ' + keydup,
            './bin/file.lint-me-anyway.yaml:4:17: ' + trailing,
            './bin/file.lint-me-anyway.yaml:5:5: ' + hyphen,
            './bin/file.yaml:3:3: ' + keydup,
            './bin/file.yaml:4:17: ' + trailing,
            './bin/file.yaml:5:5: ' + hyphen,
            './file-at-root.yaml:3:3: ' + keydup,
            './file-at-root.yaml:4:17: ' + trailing,
            './file-at-root.yaml:5:5: ' + hyphen,
            './ign-dup/file.yaml:3:3: ' + keydup,
            './ign-dup/file.yaml:4:17: ' + trailing,
            './ign-dup/file.yaml:5:5: ' + hyphen,
            './ign-dup/sub/dir/file.yaml:3:3: ' + keydup,
            './ign-dup/sub/dir/file.yaml:4:17: ' + trailing,
            './ign-dup/sub/dir/file.yaml:5:5: ' + hyphen,
            './ign-trail/file.yaml:3:3: ' + keydup,
            './ign-trail/file.yaml:4:17: ' + trailing,
            './ign-trail/file.yaml:5:5: ' + hyphen,
            './include/ign-dup/sub/dir/file.yaml:3:3: ' + keydup,
            './include/ign-dup/sub/dir/file.yaml:4:17: ' + trailing,
            './include/ign-dup/sub/dir/file.yaml:5:5: ' + hyphen,
            './s/s/ign-trail/file.yaml:3:3: ' + keydup,
            './s/s/ign-trail/file.yaml:4:17: ' + trailing,
            './s/s/ign-trail/file.yaml:5:5: ' + hyphen,
            './s/s/ign-trail/s/s/file.yaml:3:3: ' + keydup,
            './s/s/ign-trail/s/s/file.yaml:4:17: ' + trailing,
            './s/s/ign-trail/s/s/file.yaml:5:5: ' + hyphen,
            './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:3:3: ' + keydup,
            './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:4:17: ' + trailing,
            './s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:5:5: ' + hyphen,
        )))

    def test_run_with_ignore_from_file_from_subdir(self):
        # .
        # ├── .yamllint
        # ├── .gitignore
        # ├── .ignore-key-duplicates
        # └── bin                         ← yamllint is run from here
        with open(os.path.join(self.wd, '.yamllint'), 'w') as f:
            f.write('extends: default\n'
                    'ignore-from-file: .gitignore\n'
                    'rules:\n'
                    '  key-duplicates:\n'
                    '    ignore-from-file: .ignore-key-duplicates\n')

        with open(os.path.join(self.wd, '.gitignore'), 'w') as f:
            f.write('*.dont-lint-me.yaml\n'
                    '/bin/\n'
                    '!/bin/*.lint-me-anyway.yaml\n')

        with open(os.path.join(self.wd, '.ignore-key-duplicates'), 'w') as f:
            f.write('/ign-dup\n')

        sys.stdout = StringIO()
        try:
            os.chdir(os.path.join(self.wd, 'bin'))
            with self.assertRaises(SystemExit):
                cli.run(('-f', 'parsable', '.'))
        finally:
            os.chdir(self.wd)

        os.remove(os.path.join(self.wd, '.gitignore'))
        os.remove(os.path.join(self.wd, '.ignore-key-duplicates'))

        out = sys.stdout.getvalue()
        out = '\n'.join(sorted(out.splitlines()))

        keydup = '[error] duplication of key "key" in mapping (key-duplicates)'
        trailing = '[error] trailing spaces (trailing-spaces)'
        hyphen = '[error] too many spaces after hyphen (hyphens)'

        self.assertEqual(out, '\n'.join((
            './file.lint-me-anyway.yaml:3:3: ' + keydup,
            './file.lint-me-anyway.yaml:4:17: ' + trailing,
            './file.lint-me-anyway.yaml:5:5: ' + hyphen,
        )))

    def test_run_with_ignore_from_file_from_subdir_on_root_dir(self):
        # .
        # ├── .yamllint
        # ├── .gitignore
        # ├── .ignore-key-duplicates
        # └── bin                         ← yamllint is run from here
        with open(os.path.join(self.wd, '.yamllint'), 'w') as f:
            f.write('extends: default\n'
                    'ignore-from-file: .gitignore\n'
                    'rules:\n'
                    '  key-duplicates:\n'
                    '    ignore-from-file: .ignore-key-duplicates\n')

        with open(os.path.join(self.wd, '.gitignore'), 'w') as f:
            f.write('*.dont-lint-me.yaml\n'
                    '/bin/\n'
                    '!/bin/*.lint-me-anyway.yaml\n')

        with open(os.path.join(self.wd, '.ignore-key-duplicates'), 'w') as f:
            f.write('/ign-dup\n')

        sys.stdout = StringIO()
        try:
            os.chdir(os.path.join(self.wd, 'bin'))
            with self.assertRaises(SystemExit):
                cli.run(('-f', 'parsable', '..'))
        finally:
            os.chdir(self.wd)

        os.remove(os.path.join(self.wd, '.gitignore'))
        os.remove(os.path.join(self.wd, '.ignore-key-duplicates'))

        out = sys.stdout.getvalue()
        out = '\n'.join(sorted(out.splitlines()))

        docstart = '[warning] missing document start "---" (document-start)'
        keydup = '[error] duplication of key "key" in mapping (key-duplicates)'
        trailing = '[error] trailing spaces (trailing-spaces)'
        hyphen = '[error] too many spaces after hyphen (hyphens)'

        self.assertEqual(out, '\n'.join((
            '../.yamllint:1:1: ' + docstart,
            '../bin/file.lint-me-anyway.yaml:3:3: ' + keydup,
            '../bin/file.lint-me-anyway.yaml:4:17: ' + trailing,
            '../bin/file.lint-me-anyway.yaml:5:5: ' + hyphen,
            '../file-at-root.yaml:3:3: ' + keydup,
            '../file-at-root.yaml:4:17: ' + trailing,
            '../file-at-root.yaml:5:5: ' + hyphen,
            '../ign-dup/file.yaml:4:17: ' + trailing,
            '../ign-dup/file.yaml:5:5: ' + hyphen,
            '../ign-dup/sub/dir/file.yaml:4:17: ' + trailing,
            '../ign-dup/sub/dir/file.yaml:5:5: ' + hyphen,
            '../ign-trail/file.yaml:3:3: ' + keydup,
            '../ign-trail/file.yaml:4:17: ' + trailing,
            '../ign-trail/file.yaml:5:5: ' + hyphen,
            '../include/ign-dup/sub/dir/file.yaml:3:3: ' + keydup,
            '../include/ign-dup/sub/dir/file.yaml:4:17: ' + trailing,
            '../include/ign-dup/sub/dir/file.yaml:5:5: ' + hyphen,
            '../s/s/ign-trail/file.yaml:3:3: ' + keydup,
            '../s/s/ign-trail/file.yaml:4:17: ' + trailing,
            '../s/s/ign-trail/file.yaml:5:5: ' + hyphen,
            '../s/s/ign-trail/s/s/file.yaml:3:3: ' + keydup,
            '../s/s/ign-trail/s/s/file.yaml:4:17: ' + trailing,
            '../s/s/ign-trail/s/s/file.yaml:5:5: ' + hyphen,
            '../s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:3:3: ' + keydup,
            '../s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:4:17: ' + trailing,
            '../s/s/ign-trail/s/s/file2.lint-me-anyway.yaml:5:5: ' + hyphen,
        )))

    def test_run_with_inaccessible_ignore_file_from_subdir(self):
        # .
        # ├── .yamllint
        # └── bin                         ← yamllint is run from here
        #     └── .gitignore
        with open(os.path.join(self.wd, '.yamllint'), 'w') as f:
            f.write('extends: default\n'
                    'ignore-from-file: .gitignore\n')

        with open(os.path.join(self.wd, 'bin', '.gitignore'), 'w') as f:
            f.write('*.dont-lint-me.yaml\n'
                    '/bin/\n'
                    '!/bin/*.lint-me-anyway.yaml\n')

        sys.stdout = StringIO()
        sys.stderr = StringIO()
        try:
            os.chdir(os.path.join(self.wd, 'bin'))
            with self.assertRaises(FileNotFoundError):
                cli.run(('-f', 'parsable', '.'))
        finally:
            os.chdir(self.wd)

        os.remove(os.path.join(self.wd, 'bin', '.gitignore'))

@@ -109,6 +113,11 @@ def parse(self, raw_content):
raise YamlLintConfigError(
'invalid config: ignore-from-file should contain '
'filename(s), either as a list or string')
if self.config_dir is not None:
conf['ignore-from-file'] = [
f if os.path.basename(f) != f
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I don't understand this line: why should f be treated differently if it contains slashes?

  • To avoid bugs is it possible not to modify the conf object (nor conf['ignore-from-file']) if it's not strictly needed? E.g. create a local variable filepaths?

  • Maybe it's worth using os.path.relpath(filepath, start=self.config_dir) here? I'm not sure, I haven't tested. The advantage is that we wouldn't need the if self.config_dir is not None: above, because os.path.relpath() can be called with start=None or start=''.

@@ -227,6 +227,7 @@ or:
ignore-from-file: [.gitignore, .yamlignore]

.. note:: However, this is mutually exclusive with the ``ignore`` key.
Also, this filepath is considered relative to the config file.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would find it clearer in a new note below. Also, we could avoid any misunderstandings like this:

Suggested change
Also, this filepath is considered relative to the config file.
.. note::
This file (e.g. ``.gitignore``) is searched relatively to the config file
(``.yamllint``). The paths of files to ignore are interpreted relatively to
the ignore file (e.g. ``.gitignore``).

'!/bin/*.lint-me-anyway.yaml\n')
parsed = config.YamlLintConfig('extends: default\n'
'ignore-from-file: .gitignore\n')
assert parsed.is_file_ignored("/bin/ignored") is True
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single quotes please 😉

Comment on lines +785 to +786
if os.path.exists(os.path.join(self.wd, '.gitignore')):
os.remove(os.path.join(self.wd, '.gitignore'))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can avoid this by deterministically cleaning .gitignore and .ignore-key-duplicates at the end of each test.

(see the global code snippet for tests in my main comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants