Skip to content

Commit

Permalink
feat: ✨ Include pathspec library (#49)
Browse files Browse the repository at this point in the history
Co-authored-by: robvanderleek <[email protected]>
  • Loading branch information
robvanderleek and robvanderleek authored Dec 7, 2024
1 parent 79997a3 commit 5239051
Show file tree
Hide file tree
Showing 13 changed files with 90 additions and 98 deletions.
60 changes: 30 additions & 30 deletions codelimit/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ def list_commands(self, ctx: Context):

@cli.command(help="Check file(s)")
def check(
paths: Annotated[List[Path], typer.Argument(exists=True)],
exclude: Annotated[
Optional[list[str]], typer.Option(help="Glob patterns for exclusion")
] = None,
quiet: Annotated[
bool, typer.Option("--quiet", help="No output when successful")
] = False,
paths: Annotated[List[Path], typer.Argument(exists=True)],
exclude: Annotated[
Optional[list[str]], typer.Option(help="Glob patterns for exclusion")
] = None,
quiet: Annotated[
bool, typer.Option("--quiet", help="No output when successful")
] = False,
):
if exclude:
Configuration.excludes.extend(exclude)
Expand All @@ -41,12 +41,12 @@ def check(

@cli.command(help="Scan a codebase")
def scan(
path: Annotated[
Path, typer.Argument(exists=True, file_okay=False, help="Codebase root")
] = Path("."),
exclude: Annotated[
Optional[list[str]], typer.Option(help="Glob patterns for exclusion")
] = None
path: Annotated[
Path, typer.Argument(exists=True, file_okay=False, help="Codebase root")
] = Path("."),
exclude: Annotated[
Optional[list[str]], typer.Option(help="Glob patterns for exclusion")
] = None,
):
if exclude:
Configuration.excludes.extend(exclude)
Expand All @@ -55,14 +55,14 @@ def scan(

@cli.command(help="Show report for codebase")
def report(
path: Annotated[
Path, typer.Argument(exists=True, file_okay=False, help="Codebase root")
] = Path("."),
full: Annotated[bool, typer.Option("--full", help="Show full report")] = False,
totals: Annotated[bool, typer.Option("--totals", help="Only show totals")] = False,
fmt: Annotated[
ReportFormat, typer.Option("--format", help="Output format")
] = ReportFormat.text,
path: Annotated[
Path, typer.Argument(exists=True, file_okay=False, help="Codebase root")
] = Path("."),
full: Annotated[bool, typer.Option("--full", help="Show full report")] = False,
totals: Annotated[bool, typer.Option("--totals", help="Only show totals")] = False,
fmt: Annotated[
ReportFormat, typer.Option("--format", help="Output format")
] = ReportFormat.text,
):
report_command(path, full, totals, fmt)

Expand All @@ -75,15 +75,15 @@ def _version_callback(show: bool):

@cli.callback()
def main(
verbose: Annotated[
Optional[bool], typer.Option("--verbose", "-v", help="Verbose output")
] = False,
version: Annotated[
Optional[bool],
typer.Option(
"--version", "-V", help="Show version", callback=_version_callback
),
] = None,
verbose: Annotated[
Optional[bool], typer.Option("--verbose", "-v", help="Verbose output")
] = False,
version: Annotated[
Optional[bool],
typer.Option(
"--version", "-V", help="Show version", callback=_version_callback
),
] = None,
):
"""Code Limit: Your refactoring alarm."""
if verbose:
Expand Down
17 changes: 10 additions & 7 deletions codelimit/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,49 @@
from pathlib import Path

import typer
from pathspec import PathSpec
from pygments.lexers import get_lexer_for_filename
from pygments.util import ClassNotFound

from codelimit.common.CheckResult import CheckResult
from codelimit.common.Configuration import Configuration
from codelimit.common.Scanner import is_excluded, scan_file
from codelimit.common.lexer_utils import lex
from codelimit.languages import Languages


def check_command(paths: list[Path], quiet: bool):
check_result = CheckResult()
excludes_spec = PathSpec.from_lines("gitignore", Configuration.excludes)
for path in paths:
if path.is_file():
_handle_file_path(path, check_result)
_handle_file_path(path, check_result, excludes_spec)
elif path.is_dir():
for root, dirs, files in os.walk(path.absolute()):
files = [f for f in files if not f[0] == "."]
dirs[:] = [d for d in dirs if not d[0] == "."]
for file in files:
abs_path = Path(os.path.join(root, file))
rel_path = abs_path.relative_to(path.absolute())
if is_excluded(rel_path):
if is_excluded(rel_path, excludes_spec):
continue
check_file(abs_path, check_result)
exit_code = 1 if check_result.unmaintainable > 0 else 0
if (
not quiet
or check_result.hard_to_maintain > 0
or check_result.unmaintainable > 0
not quiet
or check_result.hard_to_maintain > 0
or check_result.unmaintainable > 0
):
check_result.report()
raise typer.Exit(code=exit_code)


def _handle_file_path(path: Path, check_result: CheckResult):
def _handle_file_path(path: Path, check_result: CheckResult, excludes_spec: PathSpec):
if not path.is_absolute():
abs_path = path.absolute().resolve()
try:
rel_path = abs_path.relative_to(Path.cwd())
if is_excluded(rel_path):
if is_excluded(rel_path, excludes_spec):
return
except ValueError:
pass
Expand Down
14 changes: 10 additions & 4 deletions codelimit/commands/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ def _report_functions(report: Report, path: Path, full: bool, fmt, console: Cons
if fmt == ReportFormat.markdown:
console.print(_report_functions_markdown(root, report_units), soft_wrap=True)
else:
console.print(_report_functions_text(root, units, report_units, full), soft_wrap=True)
console.print(
_report_functions_text(root, units, report_units, full), soft_wrap=True
)


def get_root(path: Path) -> Path | None:
Expand Down Expand Up @@ -109,17 +111,21 @@ def _report_functions_text(root, units, report_units, full) -> Text:
file_path = unit.file if root is None else root.joinpath(unit.file)
result.append(format_measurement(str(file_path), unit.measurement).append("\n"))
if not full and len(units) > REPORT_LENGTH:
result.append(f"[bold]{len(units) - REPORT_LENGTH} more rows, use --full option to get all rows[/bold]\n")
result.append(
f"[bold]{len(units) - REPORT_LENGTH} more rows, use --full option to get all rows[/bold]\n"
)
return result


def _report_functions_markdown(root: Path | None, report_units: list[ReportUnit]) -> str:
def _report_functions_markdown(
root: Path | None, report_units: list[ReportUnit]
) -> str:
result = ""
result += "| **File** | **Line** | **Column** | **Length** | **Function** |\n"
result += "| --- | ---: | ---: | ---: | --- |\n"
for unit in report_units:
file_path = unit.file if root is None else root.joinpath(unit.file)
type = '✖' if unit.measurement.value > 60 else '⚠'
type = "✖" if unit.measurement.value > 60 else "⚠"
result += (
f"| {str(file_path)} | {unit.measurement.start.line} | {unit.measurement.start.column} | "
f"{unit.measurement.value} | {type} {unit.measurement.unit_name} |\n"
Expand Down
4 changes: 2 additions & 2 deletions codelimit/commands/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
from codelimit.common.report.Report import Report
from codelimit.common.report.ReportReader import ReportReader
from codelimit.github_auth import get_github_token
from codelimit.utils import read_cached_report, upload_report, make_report_path
from codelimit.utils import upload_report, make_report_path


def upload_command(
repository: str, branch: str, report_file: Path, token: str, url: str
repository: str, branch: str, report_file: Path, token: str, url: str
):
if report_file:
report = ReportReader.from_json(report_file.read_text())
Expand Down
32 changes: 10 additions & 22 deletions codelimit/common/Scanner.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import fnmatch
import locale
import os
from datetime import datetime
Expand Down Expand Up @@ -86,15 +85,17 @@ def _scan_folder(
cached_report: Union[Report, None] = None,
add_file_entry: Union[Callable[[SourceFileEntry], None], None] = None,
):
gitignore = _read_gitignore(folder)
excludes = Configuration.excludes.copy()
gitignore_excludes = _read_gitignore(folder)
if gitignore_excludes:
excludes.extend(gitignore_excludes)
excludes_spec = PathSpec.from_lines("gitignore", excludes)
for root, dirs, files in os.walk(folder.absolute()):
files = [f for f in files if not f[0] == "."]
dirs[:] = [d for d in dirs if not d[0] == "."]
for file in files:
rel_path = Path(os.path.join(root, file)).relative_to(folder.absolute())
if is_excluded(rel_path) or (
gitignore is not None and is_excluded_by_gitignore(rel_path, gitignore)
):
if is_excluded(rel_path, excludes_spec):
continue
try:
lexer = get_lexer_for_filename(rel_path)
Expand Down Expand Up @@ -178,25 +179,12 @@ def scan_file(tokens: list[Token], language: Language) -> list[Measurement]:
return measurements


def is_excluded(path: Path):
for exclude in Configuration.excludes:
exclude_parts = exclude.split(os.sep)
if len(exclude_parts) == 1:
for part in path.parts:
if fnmatch.fnmatch(part, exclude):
return True
else:
if fnmatch.fnmatch(str(path), exclude):
return True
return False


def _read_gitignore(path: Path) -> PathSpec | None:
def _read_gitignore(path: Path) -> list[str] | None:
gitignore_path = path.joinpath(".gitignore")
if gitignore_path.exists():
return PathSpec.from_lines("gitignore", gitignore_path.read_text().splitlines())
return gitignore_path.read_text().splitlines()
return None


def is_excluded_by_gitignore(path: Path, gitignore: PathSpec):
return gitignore.match_file(path)
def is_excluded(path: Path, spec: PathSpec):
return spec.match_file(path)
2 changes: 1 addition & 1 deletion codelimit/common/gsm/Pattern.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def __init__(self, start: int, automata: DFA):
def consume(self, item) -> State | None:
for transition in self.state.transition:
predicate_id = id(transition[0])
if not predicate_id in self.predicate_map:
if predicate_id not in self.predicate_map:
self.predicate_map[predicate_id] = deepcopy(transition[0])
predicate = self.predicate_map[predicate_id]
if predicate.accept(item):
Expand Down
2 changes: 1 addition & 1 deletion codelimit/common/token_matching/predicate/Balanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ def __hash__(self):
return hash((self.left, self.right, self.depth))

def __str__(self):
return f"<Balanced {self.left} {self.right} {id(self)}>"
return f"<Balanced {self.left} {self.right} {id(self)}>"
2 changes: 1 addition & 1 deletion codelimit/common/token_matching/predicate/Keyword.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ def __hash__(self):
return hash(self.keyword)

def __str__(self):
return f'<Keyword {self.keyword}>'
return f"<Keyword {self.keyword}>"
2 changes: 1 addition & 1 deletion codelimit/common/token_matching/predicate/Symbol.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ def __hash__(self):
return hash(self.symbol)

def __str__(self):
return f'<Symbol {self.symbol}>'
return f"<Symbol {self.symbol}>"
3 changes: 1 addition & 2 deletions codelimit/common/token_matching/predicate/TokenValue.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,5 @@ def __eq__(self, other: object) -> bool:
def __hash__(self):
return hash(self.value)


def __str__(self):
return f'<TokenValue {self.value}>'
return f"<TokenValue {self.value}>"
8 changes: 4 additions & 4 deletions codelimit/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def read_cached_report(path: Path) -> Optional[Report]:


def upload_report(
report: Report, repository: str, branch: str, url: str, token: str
report: Report, repository: str, branch: str, url: str, token: str
) -> None:
result = api_post_report(report, branch, repository, url, token)
if result.ok:
Expand All @@ -44,9 +44,9 @@ def api_post_report(report, branch, repository, url, token):
f'{{{{"repository": "{repository}", "branch": "{branch}", "report":{{}}}}}}'
)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
transient=True,
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress:
progress.add_task(description=f"Uploading report to {url}", total=None)
result = requests.post(
Expand Down
5 changes: 4 additions & 1 deletion tests/commands/test_report.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from codelimit.commands.report import _report_totals_markdown, _report_functions_markdown
from codelimit.commands.report import (
_report_totals_markdown,
_report_functions_markdown,
)
from codelimit.common.LanguageTotals import LanguageTotals
from codelimit.common.Location import Location
from codelimit.common.Measurement import Measurement
Expand Down
37 changes: 15 additions & 22 deletions tests/common/test_Scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@
from pathspec import PathSpec

from codelimit.common.Configuration import Configuration
from codelimit.common.Scanner import (
scan_codebase,
is_excluded,
is_excluded_by_gitignore,
)
from codelimit.common.Scanner import scan_codebase, is_excluded
from codelimit.common.source_utils import get_location_range


Expand Down Expand Up @@ -88,30 +84,27 @@ def test_skip_hidden_files():


def test_is_excluded():
assert is_excluded(Path("venv/foo/bar.py"))
assert not is_excluded(Path("foo/bar.py"))
excludes_spec = PathSpec.from_lines("gitignore", Configuration.excludes)

Configuration.excludes = ["output"]
assert is_excluded(Path("venv/foo/bar.py"), excludes_spec)
assert not is_excluded(Path("foo/bar.py"), excludes_spec)

assert is_excluded(Path("output/foo/bar.py"))
assert not is_excluded(Path("venv/foo/bar.py"))
assert not is_excluded(Path("foo/bar.py"))
excludes_spec = PathSpec.from_lines("gitignore", ["output"])

Configuration.excludes = ["foo/bar/*"]
assert is_excluded(Path("output/foo/bar.py"), excludes_spec)
assert not is_excluded(Path("venv/foo/bar.py"), excludes_spec)
assert not is_excluded(Path("foo/bar.py"), excludes_spec)

assert is_excluded(Path("foo/bar/foobar.py"))
excludes_spec = PathSpec.from_lines("gitignore", ["foo/bar/*"])

assert is_excluded(Path("foo/bar/foobar.py"), excludes_spec)

def test_is_excluded_by_gitignore():
Configuration.excludes = ["site/"]
gitignore = PathSpec.from_lines("gitwildmatch", ["site/"])
excludes_spec = PathSpec.from_lines("gitignore", ["site/"])

assert is_excluded_by_gitignore(
Path("site/assets/javascripts/lunr/wordcut.js"), gitignore
)
assert is_excluded(Path("site/assets/javascripts/lunr/wordcut.js"), excludes_spec)

gitignore = PathSpec.from_lines("gitwildmatch", ["!site/"])
excludes_spec = PathSpec.from_lines("gitignore", ["!site/"])

assert not is_excluded_by_gitignore(
Path("site/assets/javascripts/lunr/wordcut.js"), gitignore
assert not is_excluded(
Path("site/assets/javascripts/lunr/wordcut.js"), excludes_spec
)

0 comments on commit 5239051

Please sign in to comment.