:param data: File information (project, path, tags). :param expected_config: Expected configuration for the file :param autofix: Flag to modify files, if the plugin supports it (default: True).
def__init__(self,info:FileInfo,expected_config:JsonDict,autofix=False)->None:self.info=infoself.filename=info.path_from_rootself.reporter=Reporter(info,self.violation_base_code)self.file_path:Path=self.info.project.root/self.filename# Configuration for this file as a TOML dict, taken from the style file.self.expected_config:JsonDict=expected_configor{}self.autofix=self.fixableandautofix# Dirty flag to avoid changing files without needself.dirty:bool=Falseself._merge_special_configs()
defpredefined_special_config(self)->SpecialConfig:"""Create a predefined special configuration for this plugin. Each plugin can override this method. """returnSpecialConfig()
@MypyPropertydefnitpick_file_dict(self)->JsonDict:"""Nitpick configuration for this file as a TOML dict, taken from the style file."""returnsearch_json(self.info.project.nitpick_section,f'files."{self.filename}"',{})
defentry_point(self)->Iterator[Fuss]:"""Entry point of the Nitpick plugin."""self.post_init()should_exist:bool=bool(self.info.project.nitpick_files_section.get(self.filename,True))ifself.file_path.exists()andnotshould_exist:logger.info(f"{self}: File {self.filename} exists when it should not")# Only display this message if the style is valid.yieldself.reporter.make_fuss(SharedViolations.DELETE_FILE)returnhas_config_dict=bool(self.expected_configorself.nitpick_file_dict)ifnothas_config_dict:returnyield fromself._enforce_file_configuration()
defpost_init(self):# noqa: B027"""Hook for plugin initialization after the instance was created. The name mimics `__post_init__()` on dataclasses, without the magic double underscores. See the [dataclasses documentation](https://docs.python.org/3/library/dataclasses.html#post-init-processing) for more details. """
defwrite_file(self,file_exists:bool,# pylint: disable=unused-argument # noqa: ARG002)->Fuss|None:"""Hook to write the new file when autofix mode is on. Should be used by inherited classes. """returnNone
defwrite_initial_contents(self,doc_class:type[BaseDoc],expected_dict:dict|None=None)->str:"""Helper to write initial contents based on a format."""ifnotexpected_dict:expected_dict=self.expected_configformatted_str=doc_class(obj=expected_dict).reformattedifself.autofix:self.file_path.parent.mkdir(exist_ok=True,parents=True)self.file_path.write_text(formatted_str)returnformatted_str
def__init__(self,info:FileInfo,expected_config:JsonDict,autofix=False)->None:self.info=infoself.filename=info.path_from_rootself.reporter=Reporter(info,self.violation_base_code)self.file_path:Path=self.info.project.root/self.filename# Configuration for this file as a TOML dict, taken from the style file.self.expected_config:JsonDict=expected_configor{}self.autofix=self.fixableandautofix# Dirty flag to avoid changing files without needself.dirty:bool=Falseself._merge_special_configs()
defpost_init(self):"""Post initialization after the instance was created."""self.updater=ConfigUpdater()self.comma_separated_values=set(self.nitpick_file_dict.get(COMMA_SEPARATED_VALUES,[]))ifnotself.needs_top_section:returnifall(isinstance(v,dict)forvinself.expected_config.values()):returnnew_config={TOP_SECTION:{}}forkey,valueinself.expected_config.items():ifisinstance(value,dict):new_config[key]=valuecontinuenew_config[TOP_SECTION][key]=valueself.expected_config=new_config
defwrite_file(self,file_exists:bool)->Fuss|None:"""Write the new file."""try:ifself.needs_top_section:self.file_path.write_text(self.contents_without_top_section(str(self.updater)))returnNoneiffile_exists:self.updater.update_file()else:self.updater.write(self.file_path.open("w"))exceptParsingErroraserr:returnself.reporter.make_fuss(Violations.PARSING_ERROR,cls=err.__class__.__name__,msg=err)returnNone
@staticmethoddefcontents_without_top_section(multiline_text:str)->str:"""Remove the temporary top section from multiline text, and keep the newline at the end of the file."""return"\n".join(lineforlineinmultiline_text.splitlines()ifTOP_SECTIONnotinline)+"\n"
defget_missing_output(self)->str:"""Get a missing output string example from the missing sections in an INI file."""missing=self.missing_sectionsifnotmissing:return""parser=ConfigParser()forsectioninsorted(missing,key=lambdas:"0"ifs==TOP_SECTIONelsef"1{s}"):expected_config:dict=self.expected_config[section]ifself.autofix:ifself.updater.last_block:self.updater.last_block.add_after.space(1)self.updater.add_section(section)self.updater[section].update(expected_config)self.dirty=Trueparser[section]=expected_configreturnself.contents_without_top_section(self.get_example_cfg(parser))
defenforce_rules(self)->Iterator[Fuss]:"""Enforce rules on missing sections and missing key/value pairs in an INI file."""try:yield fromself._read_file()exceptError:returnyield fromself.enforce_missing_sections()csv_sections={v.split(SECTION_SEPARATOR)[0]forvinself.comma_separated_values}missing_csv=csv_sections.difference(self.current_sections)ifmissing_csv:yieldself.reporter.make_fuss(Violations.INVALID_COMMA_SEPARATED_VALUES_SECTION,", ".join(sorted(missing_csv)))# Don't continue if the comma-separated values are invalidreturnforsectioninself.expected_sections.intersection(self.current_sections)-self.missing_sections:yield fromself.enforce_section(section)
defenforce_section(self,section:str)->Iterator[Fuss]:"""Enforce rules for a section."""expected_dict=self.expected_config[section]actual_dict={k:v.valuefork,vinself.updater[section].items()}# TODO: refactor: add a class Ini(BaseDoc) and move this dictdiffer code therefordiff_type,key,valuesindictdiffer.diff(actual_dict,expected_dict):ifdiff_type==dictdiffer.CHANGE:iff"{section}.{key}"inself.comma_separated_values:yield fromself.enforce_comma_separated_values(section,key,values[0],values[1])else:yield fromself.compare_different_keys(section,key,values[0],values[1])elifdiff_type==dictdiffer.ADD:yield fromself.show_missing_keys(section,values)
defenforce_comma_separated_values(self,section,key,raw_actual:Any,raw_expected:Any)->Iterator[Fuss]:"""Enforce sections and keys with comma-separated values. The values might contain spaces. """actual_set={s.strip()forsinraw_actual.split(",")}expected_set={s.strip()forsinraw_expected.split(",")}missing=expected_set-actual_setifnotmissing:returnjoined_values=",".join(sorted(missing))value_to_append=f",{joined_values}"ifself.autofix:self.updater[section][key].value+=value_to_appendself.dirty=Truesection_header=""ifsection==TOP_SECTIONelsef"[{section}]\n"# TODO: test: top section with separated values in https://github.com/andreoliwa/nitpick/issues/271yieldself.reporter.make_fuss(Violations.MISSING_VALUES_IN_LIST,f"{section_header}{key} = (...){value_to_append}",key=key,fixed=self.autofix,)
defcompare_different_keys(self,section,key,raw_actual:Any,raw_expected:Any)->Iterator[Fuss]:"""Compare different keys, with special treatment when they are lists or numeric."""ifisinstance(raw_actual,(int,float,bool))orisinstance(raw_expected,(int,float,bool)):# A boolean "True" or "true" has the same effect on ConfigParser files.actual=str(raw_actual).lower()expected=str(raw_expected).lower()else:actual=raw_actualexpected=raw_expectedifactual==expected:returnifself.autofix:self.updater[section][key].value=expectedself.dirty=Trueifsection==TOP_SECTION:yieldself.reporter.make_fuss(Violations.TOP_SECTION_HAS_DIFFERENT_VALUE,f"{key} = {raw_expected}",key=key,actual=raw_actual,fixed=self.autofix,)else:yieldself.reporter.make_fuss(Violations.OPTION_HAS_DIFFERENT_VALUE,f"[{section}]\n{key} = {raw_expected}",section=section,key=key,actual=raw_actual,fixed=self.autofix,)
defshow_missing_keys(self,section:str,values:list[tuple[str,Any]])->Iterator[Fuss]:"""Show the keys that are not present in a section."""parser=ConfigParser()missing_dict=dict(values)parser[section]=missing_dictoutput=self.get_example_cfg(parser)self.add_options_before_space(section,missing_dict)ifsection==TOP_SECTION:yieldself.reporter.make_fuss(Violations.TOP_SECTION_MISSING_OPTION,self.contents_without_top_section(output),self.autofix)else:yieldself.reporter.make_fuss(Violations.MISSING_OPTION,output,self.autofix,section=section)
defadd_options_before_space(self,section:str,options:dict)->None:"""Add new options before a blank line in the end of the section."""ifnotself.autofix:returnsection_obj=self.updater[section]# Collect all trailing Space blocks# We need to collect them first before detaching to avoid NotAttachedError# when there are multiple consecutive spacestrailing_spaces=[]forblockinreversed(list(section_obj.iter_blocks())):ifisinstance(block,Space):trailing_spaces.append(block)else:break# Stop at the first non-Space block# Detach all trailing spacesspace_removed=len(trailing_spaces)>0forspaceintrailing_spaces:space.detach()section_obj.update(options)self.dirty=True# Add back a single space if we removed anyifspace_removed:section_obj.last_block.add_after.space(1)
@staticmethoddefget_example_cfg(parser:ConfigParser)->str:"""Print an example of a config parser in a string instead of a file."""string_stream=StringIO()parser.write(string_stream)returnstring_stream.getvalue().strip()
def__init__(self,info:FileInfo,expected_config:JsonDict,autofix=False)->None:self.info=infoself.filename=info.path_from_rootself.reporter=Reporter(info,self.violation_base_code)self.file_path:Path=self.info.project.root/self.filename# Configuration for this file as a TOML dict, taken from the style file.self.expected_config:JsonDict=expected_configor{}self.autofix=self.fixableandautofix# Dirty flag to avoid changing files without needself.dirty:bool=Falseself._merge_special_configs()
defenforce_rules(self)->Iterator[Fuss]:"""Enforce rules for missing keys and JSON content."""json_doc=JsonDoc(path=self.file_path)blender:JsonDict=json_doc.as_object.copy()ifself.autofixelse{}comparison=Comparison(json_doc,self.expected_dict_from_contains_keys(),self.special_config)()ifcomparison.missing:yield fromself.report(SharedViolations.MISSING_VALUES,blender,comparison.missing)comparison=Comparison(json_doc,self.expected_dict_from_contains_json(),self.special_config)()ifcomparison.has_changes:yield fromchain(self.report(SharedViolations.DIFFERENT_VALUES,blender,comparison.diff),self.report(SharedViolations.MISSING_VALUES,blender,comparison.missing),)ifself.autofixandself.dirtyandblender:self.file_path.write_text(JsonDoc(obj=unflatten_quotes(blender)).reformatted)
defexpected_dict_from_contains_keys(self):"""Expected dict created from "contains_keys" values."""returnunflatten_quotes(dict.fromkeys(set(self.expected_config.get(KEY_CONTAINS_KEYS)or[]),VALUE_PLACEHOLDER))
defexpected_dict_from_contains_json(self):"""Expected dict created from "contains_json" values."""expected_config={}# TODO: feat: accept key as a jmespath expression, value is valid JSONforkey,json_stringin(self.expected_config.get(KEY_CONTAINS_JSON)or{}).items():try:expected_config[key]=json.loads(json_string)exceptjson.JSONDecodeErroraserr:# noqa: PERF203# This should not happen, because the style was already validated before.# Maybe the NIP??? code was disabled by the user?logger.error(f"{err} on {KEY_CONTAINS_JSON} while checking {self.file_path}")continuereturnexpected_config
defreport(self,violation:ViolationEnum,blender:JsonDict,change:BaseDoc|None):"""Report a violation while optionally modifying the JSON dict."""ifnotchange:returnifblender:blender.update(flatten_quotes(change.as_object))self.dirty=Trueyieldself.reporter.make_fuss(violation,change.reformatted,prefix="",fixed=self.autofix)
def__init__(self,info:FileInfo,expected_config:JsonDict,autofix=False)->None:self.info=infoself.filename=info.path_from_rootself.reporter=Reporter(info,self.violation_base_code)self.file_path:Path=self.info.project.root/self.filename# Configuration for this file as a TOML dict, taken from the style file.self.expected_config:JsonDict=expected_configor{}self.autofix=self.fixableandautofix# Dirty flag to avoid changing files without needself.dirty:bool=Falseself._merge_special_configs()
defenforce_rules(self)->Iterator[Fuss]:"""Enforce rules for missing key/value pairs in the TOML file."""toml_doc=TomlDoc(path=self.file_path)comparison=Comparison(toml_doc,self.expected_config,self.special_config)()ifnotcomparison.has_changes:returndocument=parse(toml_doc.as_string)ifself.autofixelseNoneyield fromchain(self.report(SharedViolations.DIFFERENT_VALUES,document,cast("TomlDoc",comparison.diff)),self.report(SharedViolations.MISSING_VALUES,document,cast("TomlDoc",comparison.missing),cast("TomlDoc",comparison.replace),),)ifself.autofixandself.dirty:self.file_path.write_text(dumps(document))
defreport(self,violation:ViolationEnum,document:TOMLDocument|None,change:TomlDoc|None,replacement:TomlDoc|None=None,):"""Report a violation while optionally modifying the TOML document."""ifnot(changeorreplacement):returnifself.autofix:real_change=cast("TomlDoc",replacementorchange)traverse_toml_tree(document,real_change.as_object)self.dirty=Trueto_display=cast("TomlDoc",changeorreplacement)yieldself.reporter.make_fuss(violation,to_display.reformatted.strip(),prefix="",fixed=self.autofix)
The plugin tries to preserve comments in the YAML file by using the ruamel.yaml package. It works for most cases. If your comment was removed, place them in a different place of the fil and try again. If it still doesn't work, please report a bug.
Known issue: lists like args and additional_dependencies might be joined in a single line, and comments between items will be removed. Move your comments outside these lists, and they should be preserved.
Note
No validation of .pre-commit-config.yaml will be done anymore in this generic YAML plugin. Nitpick will not validate hooks and missing keys as it did before; it's not the purpose of this package.
def__init__(self,info:FileInfo,expected_config:JsonDict,autofix=False)->None:self.info=infoself.filename=info.path_from_rootself.reporter=Reporter(info,self.violation_base_code)self.file_path:Path=self.info.project.root/self.filename# Configuration for this file as a TOML dict, taken from the style file.self.expected_config:JsonDict=expected_configor{}self.autofix=self.fixableandautofix# Dirty flag to avoid changing files without needself.dirty:bool=Falseself._merge_special_configs()
defpredefined_special_config(self)->SpecialConfig:"""Predefined special config, with list keys for .pre-commit-config.yaml and GitHub Workflow files."""spc=SpecialConfig()# pylint: disable=assigning-non-slotifself.filename==PRE_COMMIT_CONFIG_YAML:spc.list_keys.from_plugin={"repos":"hooks.id"}elifself.filename.startswith(".github/workflows"):spc.list_keys.from_plugin={"jobs.*.steps":"name"}returnspc
defenforce_rules(self)->Iterator[Fuss]:"""Enforce rules for missing data in the YAML file."""ifKEY_CONTAINSinself.expected_config:# If the expected configuration has this key, it means that this config is being handled by TextPlugin.# TODO: fix: allow a YAML file with a "contains" key on its root (how?)returnyaml_doc=YamlDoc(path=self.file_path)comparison=Comparison(yaml_doc,self._remove_yaml_subkey(self.expected_config),self.special_config)()ifnotcomparison.has_changes:returnyield fromchain(self.report(SharedViolations.DIFFERENT_VALUES,yaml_doc.as_object,cast("YamlDoc",comparison.diff)),self.report(SharedViolations.MISSING_VALUES,yaml_doc.as_object,cast("YamlDoc",comparison.missing),cast("YamlDoc",comparison.replace),),)ifself.autofixandself.dirty:yaml_doc.updater.dump(yaml_doc.as_object,self.file_path)
defreport(self,violation:ViolationEnum,yaml_object:YamlObject,change:YamlDoc|None,replacement:YamlDoc|None=None,):"""Report a violation while optionally modifying the YAML document."""ifnot(changeorreplacement):returnifself.autofix:real_change=cast("YamlDoc",replacementorchange)traverse_yaml_tree(yaml_object,real_change.as_object)self.dirty=Trueto_display=cast("YamlDoc",changeorreplacement)yieldself.reporter.make_fuss(violation,to_display.reformatted.strip(),prefix="",fixed=self.autofix)
def__init__(self,info:FileInfo,expected_config:JsonDict,autofix=False)->None:self.info=infoself.filename=info.path_from_rootself.reporter=Reporter(info,self.violation_base_code)self.file_path:Path=self.info.project.root/self.filename# Configuration for this file as a TOML dict, taken from the style file.self.expected_config:JsonDict=expected_configor{}self.autofix=self.fixableandautofix# Dirty flag to avoid changing files without needself.dirty:bool=Falseself._merge_special_configs()
defenforce_rules(self)->Iterator[Fuss]:"""Enforce rules for missing lines."""expected=OrderedSet(self._expected_lines())actual=OrderedSet(self.file_path.read_text().split("\n"))missing=expected-actualifmissing:yieldself.reporter.make_fuss(Violations.MISSING_LINES,"\n".join(sorted(missing)))
@hookimpldefcan_handle(info:FileInfo)->type[NitpickPlugin]|None:"""Handle text files."""ifTextPlugin.identify_tags&info.tags:returnTextPluginreturnNone