PK!reformat_gherkin/__init__.pyPK! r^%reformat_gherkin/ast_node/__init__.pyfrom typing import Union from .background import Background from .comment import Comment from .data_table import DataTable from .doc_string import DocString from .examples import Examples from .feature import Feature from .gherkin_document import GherkinDocument from .location import Location from .scenario import Scenario from .scenario_outline import ScenarioOutline from .step import Step from .table_cell import TableCell from .table_row import TableRow from .tag import Tag Node = Union[ Background, Comment, DataTable, DocString, Examples, Feature, GherkinDocument, Location, Scenario, ScenarioOutline, Step, TableCell, TableRow, Tag, ] PK!F "reformat_gherkin/ast_node/_base.pyfrom attr import dataclass def prepare(cls=None, slots=True, frozen=True, cmp=False): """ A common class decorator to decorate AST node classes. We can either use `@prepare` with default parameters, or `@prepare(...)` to override the default values of the parameters. By default, `cmp=False` makes the objects hashable, and the hash is an object's id. Therefore, every AST node is unique, even if they have identical attributes (think of two identical rows or steps at two different places in a document). """ wrapper = dataclass(slots=slots, frozen=frozen, cmp=cmp) if cls is None: return wrapper return wrapper(cls) PK!.F'reformat_gherkin/ast_node/background.pyfrom itertools import chain from typing import Optional, Tuple from attr import attrib from ._base import prepare from .location import Location from .step import Step @prepare class Background: location: Location = attrib(cmp=False, repr=False) keyword: str name: str steps: Tuple[Step, ...] description: Optional[str] = None def __iter__(self): yield self yield from chain.from_iterable(self.steps) PK! 55$reformat_gherkin/ast_node/comment.pyfrom attr import attrib from ._base import prepare from .location import Location def normalize_comment_text(text: str) -> str: """ Return a consistently formatted comment from the given Comment instance. All comments should have a single space between the hash sign and the content. """ # A comment always start with a hash sign normalized_text = text[1:].strip() return "# " + normalized_text @prepare class Comment: location: Location = attrib(cmp=False, repr=False) text: str = attrib(converter=normalize_comment_text) PK!"II'reformat_gherkin/ast_node/data_table.pyfrom typing import Tuple from attr import attrib from ._base import prepare from .location import Location from .table_row import TableRow @prepare class DataTable: location: Location = attrib(cmp=False, repr=False) rows: Tuple[TableRow, ...] def __iter__(self): yield self yield from self.rows PK!\'reformat_gherkin/ast_node/doc_string.pyfrom attr import attrib from ._base import prepare from .location import Location @prepare class DocString: location: Location = attrib(cmp=False, repr=False) content: str def __iter__(self): yield self PK!x<%reformat_gherkin/ast_node/examples.pyfrom typing import Optional, Tuple from attr import attrib from ._base import prepare from .location import Location from .table_row import TableRow from .tag import Tag @prepare class Examples: location: Location = attrib(cmp=False, repr=False) keyword: str name: str tags: Tuple[Tag, ...] table_header: Optional[TableRow] = None table_body: Optional[Tuple[TableRow, ...]] = None description: Optional[str] = None def __iter__(self): yield from self.tags yield self if self.table_header is not None: yield self.table_header if self.table_body is not None: yield from self.table_body PK! ϩ$reformat_gherkin/ast_node/feature.pyfrom itertools import chain from typing import Optional, Tuple, Union from attr import attrib from ._base import prepare from .background import Background from .location import Location from .scenario import Scenario from .scenario_outline import ScenarioOutline from .tag import Tag @prepare class Feature: language: str location: Location = attrib(cmp=False, repr=False) keyword: str name: str children: Tuple[Union[Background, Scenario, ScenarioOutline], ...] tags: Tuple[Tag, ...] description: Optional[str] = None def __iter__(self): yield from self.tags yield self yield from chain.from_iterable(self.children) PK!6qgg-reformat_gherkin/ast_node/gherkin_document.pyfrom typing import Optional, Tuple from ._base import prepare from .comment import Comment from .feature import Feature @prepare class GherkinDocument: comments: Tuple[Comment, ...] feature: Optional[Feature] = None def __iter__(self): yield from self.comments if self.feature is not None: yield from self.feature PK! Q R^^%reformat_gherkin/ast_node/location.pyfrom ._base import prepare @prepare(cmp=True) class Location: line: int column: int PK!gb80  %reformat_gherkin/ast_node/scenario.pyfrom itertools import chain from typing import Optional, Tuple from attr import attrib from ._base import prepare from .location import Location from .step import Step from .tag import Tag @prepare class Scenario: location: Location = attrib(cmp=False, repr=False) keyword: str name: str steps: Tuple[Step, ...] tags: Tuple[Tag, ...] description: Optional[str] = None def __iter__(self): yield from self.tags yield self yield from chain.from_iterable(self.steps) PK!-reformat_gherkin/ast_node/scenario_outline.pyfrom itertools import chain from typing import Optional, Tuple from attr import attrib from ._base import prepare from .examples import Examples from .location import Location from .step import Step from .tag import Tag @prepare class ScenarioOutline: location: Location = attrib(cmp=False, repr=False) keyword: str name: str steps: Tuple[Step, ...] tags: Tuple[Tag, ...] examples: Tuple[Examples, ...] description: Optional[str] = None def __iter__(self): yield from self.tags yield self yield from chain.from_iterable(self.steps) yield from chain.from_iterable(self.examples) PK!6,!reformat_gherkin/ast_node/step.pyfrom typing import Optional, Union from attr import attrib from ._base import prepare from .data_table import DataTable from .doc_string import DocString from .location import Location @prepare class Step: location: Location = attrib(cmp=False, repr=False) keyword: str text: str argument: Optional[Union[DataTable, DocString]] = None def __iter__(self): yield self if self.argument is not None: yield from self.argument PK!dVgĵ'reformat_gherkin/ast_node/table_cell.pyfrom attr import attrib from ._base import prepare from .location import Location @prepare class TableCell: location: Location = attrib(cmp=False, repr=False) value: str PK!K{{&reformat_gherkin/ast_node/table_row.pyfrom typing import Tuple from attr import attrib from ._base import prepare from .location import Location from .table_cell import TableCell @prepare class TableRow: location: Location = attrib(cmp=False, repr=False) cells: Tuple[TableCell, ...] def __len__(self): return len(self.cells) def __getitem__(self, item): return self.cells[item] PK!Iܮ reformat_gherkin/ast_node/tag.pyfrom attr import attrib from ._base import prepare from .location import Location @prepare class Tag: location: Location = attrib(cmp=False, repr=False) name: str PK!KIE reformat_gherkin/cli.pyfrom typing import Optional, Tuple import click from .config import read_config_file from .core import reformat from .errors import EmptySources from .options import AlignmentMode, Options, WriteBackMode from .report import Report from .utils import out from .version import __version__ @click.command() @click.argument( "src", nargs=-1, type=click.Path( exists=True, file_okay=True, dir_okay=True, readable=True, resolve_path=True ), is_eager=True, ) @click.option( "--check", is_flag=True, help=( "Don't write the files back, just return the status. Return code 0 " "means nothing would change. Return code 1 means some files would be " "reformatted. Return code 123 means there was an internal error." ), ) @click.option( "-a", "--alignment", type=click.Choice([AlignmentMode.LEFT.value, AlignmentMode.RIGHT.value]), help=( "Specify the alignment of step keywords (Given, When, Then,...). " "If specified, all statements after step keywords are left-aligned, " "spaces are inserted before/after the keywords to right/left align them. " "By default, step keywords are left-aligned, and there is a single " "space between the step keyword and the statement." ), ) @click.option( "--fast/--safe", is_flag=True, help="If --fast given, skip the sanity checks of file contents. [default: --safe]", ) @click.option( "--config", type=click.Path( exists=True, file_okay=True, dir_okay=False, readable=True, allow_dash=False ), is_eager=True, callback=read_config_file, help="Read configuration from FILE.", ) @click.version_option(version=__version__) @click.pass_context def main( ctx: click.Context, src: Tuple[str], check: bool, alignment: Optional[str], fast: bool, config: Optional[str], ) -> None: """ Reformat the given Gherkin files and all files in the given directories recursively. """ if config: out(f"Using configuration from {config}.", bold=False, fg="blue") write_back_mode = WriteBackMode.from_configuration(check) alignment_mode = AlignmentMode.from_configuration(alignment) options = Options( write_back=write_back_mode, step_keyword_alignment=alignment_mode, fast=fast ) report = Report(check=check) try: reformat(src, report, options=options) except EmptySources: out("No paths given. Nothing to do 😴") ctx.exit(0) bang = "💥 💔 💥" if report.return_code else "✨ 🍰 ✨" out(f"All done! {bang}") click.secho(str(report), err=True) ctx.exit(report.return_code) PK!qniireformat_gherkin/config.pyfrom pathlib import Path from typing import Iterable, Optional import click import yaml CONFIG_FILE = ".reformat-gherkin.yaml" SYSTEM_ROOT = Path("/").resolve() def find_project_root(srcs: Iterable[str]) -> Path: """ Return a directory containing .git, .hg, or .reformat-gherkin.yaml. That directory can be one of the directories passed in `srcs` or their common parent. If no directory in the tree contains a marker that would specify it's the project root, the root of the file system is returned. """ if not srcs: return SYSTEM_ROOT common_base = min(Path(src).resolve() for src in srcs) if common_base.is_dir(): # Append a dummy file so `parents` below returns `common_base_dir`, too. common_base /= "dummy-file" directory = SYSTEM_ROOT for directory in common_base.parents: if (directory / ".git").is_dir(): return directory if (directory / ".hg").is_dir(): return directory if (directory / CONFIG_FILE).is_file(): return directory return directory # pragma: no cover def read_config_file( ctx: click.Context, _: click.Parameter, value: Optional[str] ) -> Optional[str]: """ Inject the configuration from ".reformat-gherkin.yaml" into defaults in `ctx`. Returns the path to a successfully found and read configuration file, None otherwise. """ if not value: root = find_project_root(ctx.params.get("src", ())) path = root / CONFIG_FILE if path.is_file(): value = str(path.resolve()) else: return None try: with open(value, "r") as f: config = yaml.safe_load(f) except (yaml.YAMLError, OSError) as e: raise click.FileError( filename=value, hint=f"Error reading configuration file: {e}" ) if not config: return None if ctx.default_map is None: ctx.default_map = {} ctx.default_map.update( # type: ignore # bad types in .pyi {k.replace("--", "").replace("-", "_"): v for k, v in config.items()} ) return value PK!@1bbreformat_gherkin/core.pyimport traceback from pathlib import Path from typing import Iterator, Set, Tuple from .ast_node import GherkinDocument from .errors import ( BaseError, EmptySources, EquivalentError, InternalError, NothingChanged, StableError, ) from .formatter import LineGenerator from .options import Options, WriteBackMode from .parser import parse from .report import Report from .utils import diff, dump_to_file, err REPORT_URL = "https://github.com/ducminh-phan/reformat-gherkin/issues" def find_sources(src: Tuple[str]) -> Set[Path]: sources: Set[Path] = set() for s in src: path = Path(s).resolve() if path.is_dir(): sources.update(path.rglob("*.feature")) elif path.is_file(): # If a file was explicitly given, we don't care about its extension sources.add(path) else: # pragma: no cover err(f"Invalid path: {s}") return sources def reformat(src: Tuple[str], report: Report, *, options: Options): sources = find_sources(src) if not sources: raise EmptySources for path in sources: try: changed = reformat_single_file(path, options=options) report.done(path, changed) except Exception as e: report.failed(path, str(e)) # noinspection PyTypeChecker def reformat_single_file(path: Path, *, options: Options) -> bool: with open(path, "r", encoding="utf-8") as f: src_contents = f.read() try: dst_contents = format_file_contents(src_contents, options=options) except NothingChanged: return False if options.write_back == WriteBackMode.INPLACE: with open(path, "w", encoding="utf-8") as f: f.write(dst_contents) return True def format_file_contents(src_contents: str, *, options: Options) -> str: """ Reformat the contents of a file and return new contents. Raise NothingChanged if the contents were not changed after reformatting. If `options.fast` is False, additionally confirm that the reformatted file is valid by calling :func:`assert_equivalent` and :func:`assert_stable` on it. """ if src_contents.strip() == "": raise NothingChanged dst_contents = format_str(src_contents, options=options) if src_contents == dst_contents: raise NothingChanged if not options.fast: assert_equivalent(src_contents, dst_contents) assert_stable(src_contents, dst_contents, options=options) return dst_contents def format_str(src_contents: str, *, options: Options) -> str: """ Reformat a string and return new contents. """ ast = parse(src_contents) line_generator = LineGenerator(ast, options.step_keyword_alignment) lines = line_generator.generate() return "\n".join(lines) def assert_equivalent(src: str, dst: str) -> None: """ Raise EquivalentError if `src` and `dst` aren't equivalent. """ def _v(ast: GherkinDocument) -> Iterator[str]: """ Simple visitor generating strings to compare ASTs by content """ for node in ast: yield repr(node) src_ast = parse(src) try: dst_ast = parse(dst) except BaseError as exc: log = dump_to_file("".join(traceback.format_tb(exc.__traceback__)), dst) raise InternalError( f"INTERNAL ERROR: Invalid file contents are produced:\n" f"{exc}\n" f"Please report a bug on {REPORT_URL}.\n" f"This invalid output might be helpful:\n" f"{log}\n" ) from exc src_ast_str = "\n".join(_v(src_ast)) dst_ast_str = "\n".join(_v(dst_ast)) if src_ast_str != dst_ast_str: log = dump_to_file(diff(src_ast_str, dst_ast_str, "src", "dst")) raise EquivalentError( f"INTERNAL ERROR: The new content produced is not equivalent to " f"the source.\n" f"Please report a bug on {REPORT_URL}.\n" f"This diff might be helpful: {log}\n" ) def assert_stable(src: str, dst: str, *, options: Options) -> None: """ Raise StableError if `dst` reformats differently the second time. """ new_dst = format_str(dst, options=options) if dst != new_dst: log = dump_to_file( diff(src, dst, "source", "first pass"), diff(dst, new_dst, "first pass", "second pass"), ) raise StableError( f"INTERNAL ERROR: Different contents are produced on the second pass " f"of the formatter.\n" f"Please report a bug on {REPORT_URL}.\n" f"This diff might be helpful: {log}\n" ) from None PK!XXreformat_gherkin/errors.pyclass BaseError(Exception): pass class InvalidInput(BaseError): """ Raised when the input file cannot be parsed. """ class DeserializeError(BaseError): """ Raised when the parse result cannot be deserialized to an AST. """ class InternalError(BaseError): """ Raised when something happens anomaly in the process of reformatting. """ class EquivalentError(InternalError): """ Raised when the reformatted document is not equivalent to the original one. """ class StableError(InternalError): """ Raised when we obtain a different document after reformatting the second time. """ class EmptySources(BaseError): """ Raised when there is no file to reformat. """ class BaseWarning(Warning): pass class MissingExamplesWarning(BaseWarning): """ Raised when examples are missing in ScenarioOutline """ class EmptyExamplesWarning(BaseWarning): """ Raised when an examples table is empty """ class NothingChanged(BaseWarning): """ Raised when reformatted code is the same as source. """ PK!^P`'`'reformat_gherkin/formatter.pyfrom itertools import chain, zip_longest from typing import Any, Dict, Iterator, List, Optional, Set, Union from attr import attrib, dataclass from .ast_node import ( Background, Comment, DataTable, DocString, Examples, Feature, GherkinDocument, Node, Scenario, ScenarioOutline, Step, TableRow, Tag, ) from .options import AlignmentMode from .utils import camel_to_snake_case, extract_beginning_spaces, get_step_keywords INDENT = " " INDENT_LEVEL_MAP = { Feature: 0, Background: 1, Scenario: 1, ScenarioOutline: 1, Step: 2, Examples: 2, TableRow: 3, } def generate_step_line( step: Step, keyword_alignment: AlignmentMode, *, dialect_name: str = "en" ) -> str: """ Generate lines for steps. The step keywords are aligned according to the parameter `keyword_alignment`. For example: If `keyword_alignment = AlignmentMode.NONE`: Given Enter search term 'Cucumber' When Do search Then Single result is shown for 'Cucumber' If `keyword_alignment = AlignmentMode.LEFT`: Given Enter search term 'Cucumber' When Do search Then Single result is shown for 'Cucumber' If `keyword_alignment = AlignmentMode.Right`: Given Enter search term 'Cucumber' When Do search Then Single result is shown for 'Cucumber' """ indent_level: int = INDENT_LEVEL_MAP[Step] formatted_keyword = format_step_keyword( step.keyword, keyword_alignment, dialect_name=dialect_name ) return f"{INDENT * indent_level}{formatted_keyword} {step.text}" def format_step_keyword( keyword: str, keyword_alignment: AlignmentMode, *, dialect_name: str = "en" ) -> str: """ Insert padding to step keyword if necessary based on how we want to align them. """ if keyword_alignment is AlignmentMode.NONE: return keyword all_keywords = get_step_keywords(dialect_name) max_keyword_length = max(map(len, all_keywords)) padding = " " * (max_keyword_length - len(keyword)) if keyword_alignment is AlignmentMode.LEFT: return keyword + padding else: return padding + keyword def generate_keyword_line(keyword: str, name: str, indent_level: int) -> str: return f"{INDENT * indent_level}{keyword}: {name}".rstrip() def generate_description_lines( description: Optional[str], indent_level: int ) -> List[str]: description_lines = extract_description_lines(description) lines = [f"{INDENT * indent_level}{line}" for line in description_lines] # Add an empty line after the description, if it exists if lines: lines.append("") return lines def extract_description_lines(description: Optional[str]) -> List[str]: if description is None: return [] return description.splitlines() def generate_table_lines(rows: List[TableRow]) -> List[str]: """ Generate lines for table. The columns in a table need to have the same width. """ if not rows: return [] indent_level = INDENT_LEVEL_MAP[TableRow] n_columns = len(rows[0]) # Find the max width of a cell in a column, so that every cell in the same column # has the same width column_widths = [ max(len(row[column_index].value) for row in rows) for column_index in range(n_columns) ] lines = [] for row in rows: line = "|" for column_index in range(n_columns): # Left-align the content of each cell, fix the width of the cell content = row[column_index].value width = column_widths[column_index] line += f" {content:<{width}} |" lines.append(line) return [f"{INDENT * indent_level}{line}" for line in lines] def extract_rows(node: Union[DataTable, Examples]) -> List[TableRow]: """ Extract table rows from either a Datable or Example instance. """ if isinstance(node, DataTable): return list(node.rows) rows = [] if isinstance(node, Examples): header = node.table_header body = node.table_body if header is not None: rows.append(header) if body is not None: rows.extend(body) return rows def generate_doc_string_lines(docstring: DocString) -> List[str]: raw_lines = docstring.content.splitlines() raw_lines = ['"""'] + raw_lines + ['"""'] indent_level = INDENT_LEVEL_MAP[Step] return [f"{INDENT * indent_level}{line}" for line in raw_lines] ContextMap = Dict[Union[Comment, Tag, TableRow], Any] Lines = Iterator[str] @dataclass class LineGenerator: ast: GherkinDocument step_keyword_alignment: AlignmentMode __nodes: List[Node] = attrib(init=False) __contexts: ContextMap = attrib(init=False) __nodes_with_newline: Set[Node] = attrib(init=False) def __attrs_post_init__(self): # Use `__attrs_post_init__` instead of `property` to avoid re-computing attributes self.__nodes = sorted(list(self.ast), key=lambda node: node.location) self.__contexts = self.__construct_contexts() self.__nodes_with_newline = self.__find_nodes_with_newline() def __construct_contexts(self) -> ContextMap: """ Construct the information about the context a certain line might need to know to properly format these lines. """ contexts: ContextMap = {} nodes = self.__nodes for node in nodes: # We want tags to have the same indentation level with their parents for tag in getattr(node, "tags", []): contexts[tag] = node if isinstance(node, (DataTable, Examples)): # We need to know all rows in a table, so that the columns can be padded # to have the same widths across all rows. The context of a row is its # reformatted line. rows = extract_rows(node) lines = generate_table_lines(rows) for row, line in zip(rows, lines): contexts[row] = line for node, next_node in zip_longest(nodes, nodes[1:], fillvalue=None): if isinstance(node, Comment): # We want comments to have the same indentation level with the next line contexts[node] = next_node return contexts def __find_nodes_with_newline(self) -> Set[Node]: """ Find all nodes in the AST that needs a new line after it. """ nodes_with_newline: Set[Node] = set() node: Optional[Node] = None for node in self.__nodes: # We want to add a newline after the Feature line, even if it does not # have a description. If the feature has a description, we already add # a newline after each description. if isinstance(node, Feature) and node.description is None: nodes_with_newline.add(node) children: List[Node] = [] # Add an empty line after the last step, including its argument, if any if isinstance(node, (Background, Scenario, ScenarioOutline)): children = list(chain.from_iterable(node.steps)) # Add an empty line after an examples table if isinstance(node, Examples): children = list(node) if children: last_child = children[-1] nodes_with_newline.add(last_child) # Add the last node in the AST so that we have an empty line at the end if node is not None: nodes_with_newline.add(node) return nodes_with_newline def generate(self) -> Lines: for node in self.__nodes: yield from self.visit(node) if node in self.__nodes_with_newline: yield "" def visit(self, node: Node) -> Lines: class_name = type(node).__name__ yield from getattr( self, f"visit_{camel_to_snake_case(class_name)}", self.visit_default )(node) @staticmethod def visit_default(node: Node) -> Lines: indent_level = INDENT_LEVEL_MAP.get(type(node), 0) if hasattr(node, "keyword") and hasattr(node, "name"): yield generate_keyword_line( node.keyword, node.name, indent_level # type: ignore ) if hasattr(node, "description"): yield from generate_description_lines( node.description, indent_level + 1 # type: ignore ) def visit_step(self, step: Step) -> Lines: yield generate_step_line(step, self.step_keyword_alignment) def visit_tag(self, tag: Tag) -> Lines: context = self.__contexts[tag] # Every node type containing tags is included in the indent map, so we don't # have to worry about KeyError here indent_level = INDENT_LEVEL_MAP[type(context)] yield f"{INDENT * indent_level}{tag.name}" def visit_table_row(self, row: TableRow) -> Lines: context = self.__contexts[row] yield context def visit_comment(self, comment: Comment) -> Lines: context = self.__contexts[comment] # Find the indent level of this comment line if context is None: # In this case, this comment line is the last line of the document indent_level: Optional[int] = 0 else: # Try to look for the indent level of the context in the mapping. If not # successful, then we use the same amount of white spaces to indent as # the next line. indent_level = INDENT_LEVEL_MAP.get(type(context)) if indent_level is None: next_line = next(self.visit(context)) indent = extract_beginning_spaces(next_line) else: indent = INDENT * indent_level yield f"{indent}{comment.text}" @staticmethod def visit_doc_string(docstring: DocString) -> Lines: yield from generate_doc_string_lines(docstring) PK!>reformat_gherkin/options.pyfrom enum import Enum, unique from typing import Optional from attr import dataclass @unique class WriteBackMode(Enum): INPLACE = "inplace" CHECK = "check" @classmethod def from_configuration(cls, check: bool) -> "WriteBackMode": return WriteBackMode.CHECK if check else WriteBackMode.INPLACE @unique class AlignmentMode(Enum): NONE = None LEFT = "left" RIGHT = "right" @classmethod def from_configuration(cls, alignment: Optional[str]) -> "AlignmentMode": return AlignmentMode(alignment) @dataclass(frozen=True, kw_only=True) class Options: write_back: WriteBackMode step_keyword_alignment: AlignmentMode fast: bool PK!6reformat_gherkin/parser.pyfrom typing import Any, Dict, Type from cattr.converters import Converter from gherkin.errors import ParserError from gherkin.parser import Parser from .ast_node.gherkin_document import GherkinDocument from .errors import DeserializeError, InvalidInput from .utils import camel_to_snake_case, strip_spaces class CustomConverter(Converter): def structure_attrs_fromdict(self, obj: Dict[str, Any], cls: Type) -> Any: # Make sure the type in the parsed object matches the class we use # to structure the object if "type" in obj: type_name = obj.pop("type") cls_name = cls.__name__ assert type_name == cls_name, f"{type_name} does not match {cls_name}" # Note that keys are in camelCase convention, for example, tableHeader, # tableBody. Therefore, we need to convert the keys to snake_case. transformed_obj = {} for key, value in obj.items(): if isinstance(value, str): # For some types of node, the indentation of the lines is included # in the value of such nodes. Then the indentation can be changed after # formatting. Therefore, we need to strips spaces around each line of # the value here for consistent results. This also removes trailing # spaces. value = strip_spaces(value) transformed_obj[camel_to_snake_case(key)] = value return super(CustomConverter, self).structure_attrs_fromdict( transformed_obj, cls ) converter = CustomConverter() def parse(content: str) -> GherkinDocument: """ Parse the content of a file to an AST. """ parser = Parser() try: parse_result = parser.parse(content) except ParserError as e: raise InvalidInput(e) from e try: result = converter.structure(parse_result, GherkinDocument) except Exception as e: raise DeserializeError(f"{type(e).__name__}: {e}") from e return result PK!j9 9 reformat_gherkin/report.pyfrom pathlib import Path import click from attr import dataclass from .utils import err, out @dataclass class Report: """Provides a reformatting counter. Can be rendered with `str(report)`.""" check: bool change_count: int = 0 same_count: int = 0 failure_count: int = 0 def done(self, path: Path, changed: bool) -> None: """Increment the counter for successful reformatting. Write out a message.""" if changed: reformatted = "Would reformat" if self.check else "Reformatted" out(f"{reformatted} {path}") self.change_count += 1 else: self.same_count += 1 def failed(self, path: Path, message: str) -> None: """Increment the counter for failed reformatting. Write out a message.""" err(f"Error: cannot format {path}: {message}") self.failure_count += 1 @property def return_code(self) -> int: """Return the exit code that the app should use. This considers the current state of changed files and failures: - if there were any failures, return 123; - if any files were changed and --check is being used, return 1; - otherwise return 0. """ # According to http://tldp.org/LDP/abs/html/exitcodes.html starting with # 126 we have special return codes reserved by the shell. if self.failure_count: return 123 elif self.change_count and self.check: return 1 return 0 def __str__(self) -> str: """Render a color report of the current state. Use `click.unstyle` to remove colors. """ if self.check: reformatted = "would be reformatted" unchanged = "would be left unchanged" failed = "would fail to reformat" else: reformatted = "reformatted" unchanged = "left unchanged" failed = "failed to reformat" report_lines = [] if self.change_count: s = "s" if self.change_count > 1 else "" report_lines.append( click.style(f"{self.change_count} file{s} {reformatted}", bold=True) ) if self.same_count: s = "s" if self.same_count > 1 else "" report_lines.append(f"{self.same_count} file{s} {unchanged}") if self.failure_count: s = "s" if self.failure_count > 1 else "" report_lines.append( click.style(f"{self.failure_count} file{s} {failed}", fg="red") ) return ", ".join(report_lines) + "." PK!*reformat_gherkin/utils.pyimport difflib import re import tempfile from functools import lru_cache, partial from typing import List import click from gherkin.dialect import Dialect out = partial(click.secho, bold=True, err=True) err = partial(click.secho, fg="red", err=True) _first_cap_re = re.compile(r"(.)([A-Z][a-z]+)") _all_cap_re = re.compile(r"([a-z0-9])([A-Z])") def camel_to_snake_case(name: str) -> str: """ Convert camelCase to snake_case. Taken from https://stackoverflow.com/a/1176023/2585762. """ s1 = _first_cap_re.sub(r"\1_\2", name) return _all_cap_re.sub(r"\1_\2", s1).lower() def dump_to_file(*output: str) -> str: """ Dump `output` to a temporary file. Return path to the file. """ with tempfile.NamedTemporaryFile( mode="w", prefix="rfmt-ghk_", suffix=".log", delete=False, encoding="utf-8" ) as f: for lines in output: f.write(lines) if lines and lines[-1] != "\n": f.write("\n") return f.name def diff(a: str, b: str, a_name: str, b_name: str) -> str: """Return a unified diff string between strings `a` and `b`.""" a_lines = [line + "\n" for line in a.split("\n")] b_lines = [line + "\n" for line in b.split("\n")] return "".join( difflib.unified_diff(a_lines, b_lines, fromfile=a_name, tofile=b_name, n=5) ) @lru_cache() def get_step_keywords(dialect_name="en") -> List[str]: dialect = Dialect.for_name(dialect_name) keywords = ( dialect.given_keywords + dialect.when_keywords + dialect.then_keywords + dialect.and_keywords + dialect.but_keywords ) return [kw.strip() for kw in keywords] _beginning_spaces_re = re.compile(r"^(\s*).*") def extract_beginning_spaces(string: str) -> str: return _beginning_spaces_re.findall(string)[0] def strip_spaces(string: str) -> str: lines = string.splitlines() return "\n".join(line.strip() for line in lines) PK!ã&reformat_gherkin/version.py__version__ = "0.3.0" PK!HT&p4>1reformat_gherkin-0.3.0.dist-info/entry_points.txtN+I/N.,()*JM/M,MH-̳ Cs2r3PK!_x..(reformat_gherkin-0.3.0.dist-info/LICENSEMIT License Copyright (c) 2019 Duc-Minh Phan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HnHTU&reformat_gherkin-0.3.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!HNftz)reformat_gherkin-0.3.0.dist-info/METADATAXko8_Ehd;cjۦ NMvCDKH ?^Ϥg $sO2^UiSOI2~Vy읮5f&読*i0Тu\E2\MD}&a}ΒTìM+]qSzxOgځuM/OcUI]N,US,tKV[@h˛3pVJ\+4Mj/x&bNځ~ښڙR@kLemiUqIsiYtf\U缪-f!߁_Z[YUD <JWųru=.yh;צ)=2sVne}':Z(mvu/oƯ(c5hJFףd ^}f>ש%CA+ZxTiD kP?^}C]fu]ˬۧjV.SExSId=K|9CX B)?(KhƸp&\P·Djo g]+*3ͪ {h dܩ4KnK4 ֦*16 `Ywe4]c%P]7n냑)|W°Y)ӻ?1-'# gO(h f_=LSndC9)ULn8~r<9ӓW'rNwJPRGZJ%\(7ogO$}'CzYNbNdzҪVڋDYt`Qt]h'1\% $V"RL34%Xb ȥ̀rd/E:Kh):c-fJL-IqkTb1g&lϞ2qkmRsbR3-Kh}uۆ5m]7 `-Li+ &DcMkjc04~apIr*[aQnevj (zqvkј:+ "O{cHt:P]+"0v[PИ܊ D+>V\}=MOg1 Um@(pf)ԥ[,m%傧8N 1罩x8 rgWBF;ǃ-LFHD$26/ni2;[hbp^vWC1>9YS]fFЅjdy9a)B\5*󎽾ѩqOAR\1 GU(^w5, cנdh)j{BAa-Û/Lp!1Cqe[~-rauDdNT*/W,:9V41ąsG{w8N7AYwIBAK@"nzNzᷛfc1d&=L57V-Kj\fF@AMo9Pe=+zrDE|'YlѼ9h=S8+,]XȲEW[wsAroV;Дec*og3A$ݨ-xf˂Ӫ0zF7֔ ^GYD,d>c4i;أ*[b17Ըu7 8™2th; 0è^+ 'l0y}A_!iVDmOx؃6_i3bԟ,j&e2xy<Ñ,㟔Ia鰂̉a 4T0a+n@h4~n8sxgnKPAGC_lKY+mW7|z'Y쒹eBV5(f'k# .C-l& |UCm"7غ#'qچ=kDF|C2:A%H :fQ"wnai'S)ɒ->Z(0"n$#DarQF!O#4qZ+6H;)|2Lq<3,0ʝ³[+ѡq4!yY_/ 'όzRL,1?I(W/pob'w3Mc3]D5X[;ѽΌhw}ٝЂ@ <Dp{cXWxt )Dw+La1ݝH1oZf?mx霙2;=KH[v%/y|Ӿ:,;nt!dZюfZ`a؛k8a<5?ע?xnQ1I67#:RA4lR!0B9r尬n4 ~*3xwɦ~߅LJ LͰ!_=G ;O;دwl5Ia)m,:u^m[[OlY mX?R'ѠjoqVtܘ&B-9퇟_:UKB=*^܅"4*QOyB0fז藠18s<|Avw~Ke3A3:Qd~ (`,e4;l ;p}s1oYmFXB~ٌnox,RN׳gm[Nz}|?fpVa n'^O~ of c&5 ;YMUP.P( 8h9'PK!reformat_gherkin/__init__.pyPK! r^%:reformat_gherkin/ast_node/__init__.pyPK!F "<reformat_gherkin/ast_node/_base.pyPK!.F'reformat_gherkin/ast_node/background.pyPK! 55$"reformat_gherkin/ast_node/comment.pyPK!"II'큙 reformat_gherkin/ast_node/data_table.pyPK!\'' reformat_gherkin/ast_node/doc_string.pyPK!x<%O reformat_gherkin/ast_node/examples.pyPK! ϩ$:reformat_gherkin/ast_node/feature.pyPK!6qgg-%reformat_gherkin/ast_node/gherkin_document.pyPK! Q R^^%reformat_gherkin/ast_node/location.pyPK!gb80  %xreformat_gherkin/ast_node/scenario.pyPK!-reformat_gherkin/ast_node/scenario_outline.pyPK!6,!큚reformat_gherkin/ast_node/step.pyPK!dVgĵ'클reformat_gherkin/ast_node/table_cell.pyPK!K{{&큮reformat_gherkin/ast_node/table_row.pyPK!Iܮ mreformat_gherkin/ast_node/tag.pyPK!KIE Y reformat_gherkin/cli.pyPK!qnii+reformat_gherkin/config.pyPK!@1bb3reformat_gherkin/core.pyPK!XXKFreformat_gherkin/errors.pyPK!^P`'`'Jreformat_gherkin/formatter.pyPK!>vrreformat_gherkin/options.pyPK!6aureformat_gherkin/parser.pyPK!j9 9 큏}reformat_gherkin/report.pyPK!*reformat_gherkin/utils.pyPK!ã&reformat_gherkin/version.pyPK!HT&p4>13reformat_gherkin-0.3.0.dist-info/entry_points.txtPK!_x..(reformat_gherkin-0.3.0.dist-info/LICENSEPK!HnHTU&*reformat_gherkin-0.3.0.dist-info/WHEELPK!HNftz)•reformat_gherkin-0.3.0.dist-info/METADATAPK!HfL 'reformat_gherkin-0.3.0.dist-info/RECORDPK