Skip to content

Commit

Permalink
✨ Issue 15 invoking without tui (#16)
Browse files Browse the repository at this point in the history
  • Loading branch information
robvanderleek authored Aug 16, 2023
1 parent 167f154 commit 5dda87d
Show file tree
Hide file tree
Showing 12 changed files with 213 additions and 118 deletions.
3 changes: 2 additions & 1 deletion .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name: codelimit
description: "CodeLimit: Your Refactoring Alarm"
verbose: true
entry: hook
entry: codelimit check --quiet
require_serial: true
language: python
types: [file, python]
50 changes: 44 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Your Refactoring Alarm

# Quickstart

## Installation
## Pre-commit hook

CodeLimit can be installed as a [pre-commit](https://pre-commit.com/) hook so
it alarms you during development when it's time to refactor:
Expand All @@ -26,8 +26,11 @@ it alarms you during development when it's time to refactor:
```
CodeLimit is intended to be used alongside formatting, linters and other hooks
that improve the consistency and quality of your code (such as Black, Ruff and
MyPy.) As an example pre-commit configuration see the
that improve the consistency and quality of your code (such as
[Black](https://github.com/psf/black),
[Ruff](https://github.com/astral-sh/ruff) and
[MyPy](https://github.com/python/mypy).) As an example pre-commit configuration
see the
[`pre-commit-config.yaml`](https://github.com/getcodelimit/codelimit/blob/main/.pre-commit-config.yaml)
from CodeLimit itself.

Expand All @@ -41,13 +44,48 @@ To show your project uses CodeLimit place this badge in the README markdown:

## Standalone

CodeLimit can also run as a standalone program. To install the standalone
version of CodeLimit for your default Python installation run:

```shell
python -m pip install codelimit
```

Run CodeLimit without arguments to see the usage page:

```shell
$ codelimit
Usage: codelimit [OPTIONS] COMMAND [ARGS]...
CodeLimit: Your refactoring alarm
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ check Check file(s) │
│ scan Scan a codebase │
╰──────────────────────────────────────────────────────────────────────────────╯
```

## Scanning a codebase

To scan a complete codebase and launch the TUI, run:

```shell
codelimit scan path/to/codebase
```

![Screenshot](https://github.com/getcodelimit/codelimit/blob/main/docs/screenshot.png)

To install the standalone version of CodeLimit for your default Python
installation run:
## Checking files

To check a single file or list of files for functions that need refactoring,
run:

```shell
python -m pip install codelimit
codelimit check a.py b.py c.py
```

# Development
Expand Down
120 changes: 57 additions & 63 deletions codelimit/__main__.py
Original file line number Diff line number Diff line change
@@ -1,89 +1,83 @@
import os
from pathlib import Path
from typing import List
from typing import List, Annotated

import typer

from codelimit.common.Scanner import scan, scan_file
from codelimit.common.CheckResult import CheckResult
from codelimit.common.Scanner import scan_codebase
from codelimit.common.report.Report import Report
from codelimit.common.report.ReportReader import ReportReader
from codelimit.common.report.ReportUnit import ReportUnit, format_report_unit
from codelimit.common.report.ReportWriter import ReportWriter
from codelimit.languages.python.PythonLanguage import PythonLanguage
from codelimit.tui.CodeLimitApp import CodeLimitApp
from codelimit.utils import upload_report
from rich import print
from codelimit.utils import upload_report, check_file

cli = typer.Typer()
pre_commit_hook = typer.Typer()
cli = typer.Typer(no_args_is_help=True, add_completion=False)


@cli.callback(invoke_without_command=True)
def cli_callback(
path: Path,
report_path: Path = typer.Option(
None,
"--report",
"-r",
help="JSON report for a code base",
),
upload: bool = typer.Option(False, "--upload", help="Upload a report"),
url: str = typer.Option(
"https://codelimit-web.vercel.app/api/upload",
"--url",
"-u",
help="Upload JSON report to this URL.",
),
) -> None:
"""CodeLimit: Your refactoring alarm"""

report_path = report_path or path.joinpath("codelimit.json").resolve()
@cli.command(help="Check file(s)")
def check(
paths: Annotated[List[Path], typer.Argument(exists=True, help="Codebase root")],
quiet: Annotated[
bool, typer.Option("--quiet", help="Not output when successful")
] = False,
):
check_result = CheckResult()
for path in paths:
if path.is_file():
check_result.add(path, check_file(path))
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:
file_path = Path(os.path.join(root, file))
check_result.add(file_path, check_file(file_path))
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
):
check_result.report()
raise typer.Exit(code=exit_code)

if upload:
try:
upload_report(report_path, url)
raise typer.Exit(code=0)
except FileNotFoundError as error:
typer.secho(f"File not found: {error}", fg="red")
raise typer.Exit(code=1) from None

@cli.command(help="Scan a codebase")
def scan(path: Annotated[Path, typer.Argument()] = Path(".")):
report_path = path.joinpath("codelimit.json").resolve()
if not report_path.exists():
codebase = scan(path)
codebase = scan_codebase(path)
codebase.aggregate()
report = Report(codebase)
report_path.write_text(ReportWriter(report).to_json())
else:
report = ReportReader.from_json(report_path.read_text())

app = CodeLimitApp(report)
app.run()


@pre_commit_hook.callback(invoke_without_command=True)
def pre_commit_hook_callback(paths: List[Path]):
exit_code = 0
language = PythonLanguage()
for path in paths:
measurements = scan_file(language, str(path))
medium_risk = sorted(
[m for m in measurements if 30 < m.value <= 60],
key=lambda measurement: measurement.value,
reverse=True,
)
high_risk = sorted(
[m for m in measurements if m.value > 60],
key=lambda measurement: measurement.value,
reverse=True,
)
if medium_risk:
print(f"🔔 {path}")
for m in medium_risk:
print(format_report_unit(ReportUnit(str(path), m)))
if high_risk:
print(f"🚨 {path}")
for m in high_risk:
print(format_report_unit(ReportUnit(str(path), m)))
exit_code = 1
raise typer.Exit(code=exit_code)
@cli.command(help="Upload report to CodeLimit server", hidden=True)
def upload(
report_path: Path = typer.Argument(help="JSON report for a code base"),
url: str = typer.Option(
"https://codelimit-web.vercel.app/api/upload",
"--url",
"-u",
help="Upload JSON report to this URL.",
),
):
try:
upload_report(report_path, url)
raise typer.Exit(code=0)
except FileNotFoundError as error:
typer.secho(f"File not found: {error}", fg="red")
raise typer.Exit(code=1) from None


@cli.callback()
def main():
"""CodeLimit: Your refactoring alarm."""


if __name__ == "__main__":
Expand Down
56 changes: 56 additions & 0 deletions codelimit/common/CheckResult.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import os
from os.path import relpath
from pathlib import Path

import rich
from rich.console import Console
from rich.style import Style
from rich.text import Text

from codelimit.common.Measurement import Measurement
from codelimit.common.utils import format_unit


class CheckResult:
def __init__(self):
self.file_list: list[tuple[Path, list[Measurement]]] = []
self.hard_to_maintain = 0
self.unmaintainable = 0

def add(self, file: Path, measurements: list[Measurement]):
self.file_list.append((file, measurements))
self.hard_to_maintain += len([m for m in measurements if 30 < m.value <= 60])
self.unmaintainable += len([m for m in measurements if m.value > 60])

def __len__(self):
return len(self.file_list)

def report(self):
cwd_path = Path(os.getcwd())
stdout = Console()
for file, measurements in self.file_list:
for m in measurements:
text = Text()
if cwd_path in file.parents:
text.append(relpath(file, cwd_path), style=Style(bold=True))
else:
text.append(str(file), style="bold")
text.append(":", style=Style(color="cyan"))
text.append(str(m.start.line))
text.append(":", style=Style(color="cyan"))
text.append(str(m.start.column))
text.append(":", style=Style(color="cyan"))
text.append(" ")
text.append(format_unit(m.unit_name, m.value))
stdout.print(text, soft_wrap=True)
if self.hard_to_maintain > 0 or self.unmaintainable > 0:
rich.print(
f"{len(self.file_list)} files checked, "
f"{self.hard_to_maintain + self.unmaintainable} functions need "
f"refactoring."
)
else:
rich.print(
f"{len(self.file_list)} files checked, :sparkles: Refactoring not "
f"necessary :sparkles:, happy coding!"
)
2 changes: 1 addition & 1 deletion codelimit/common/Scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
] # , CLanguage(), JavaScriptLanguage(), TypeScriptLanguage()]


def scan(path: Path) -> Codebase:
def scan_codebase(path: Path) -> Codebase:
codebase = Codebase(str(path.absolute()))
_scan_folder(codebase, path)
return codebase
Expand Down
18 changes: 0 additions & 18 deletions codelimit/common/report/ReportUnit.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,9 @@
from dataclasses import dataclass

from rich.text import Text

from codelimit.common.Measurement import Measurement


@dataclass
class ReportUnit:
file: str
measurement: Measurement


def format_report_unit(unit: ReportUnit) -> Text:
name = unit.measurement.unit_name
length = unit.measurement.value
if length > 60:
style = "red"
elif length > 30:
style = "dark_orange"
elif length > 15:
style = "yellow"
else:
style = "green"
length_text = f"{length:3}" if length < 61 else "60+"
styled_text = Text(length_text, style=style)
return Text.assemble("[", styled_text, "] ", name)
16 changes: 16 additions & 0 deletions codelimit/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import sys
from typing import Union, Any

from rich.text import Text

from codelimit.common.Measurement import Measurement
from codelimit.version import version, release_date

Expand Down Expand Up @@ -78,3 +80,17 @@ def isatty(stream) -> bool:
def header(content: str):
print(f"Code Limit (v. {version}, build date: {release_date})".center(80))
print(content)


def format_unit(name: str, length: int) -> Text:
if length > 60:
style = "red"
elif length > 30:
style = "dark_orange"
elif length > 15:
style = "yellow"
else:
style = "green"
length_text = f"{length:3}" if length < 61 else "60+"
styled_text = Text(length_text, style=style)
return Text.assemble("[", styled_text, "] ", name)
11 changes: 9 additions & 2 deletions codelimit/tui/CodeLimitApp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from textual.widgets import Footer, ListView, ListItem, Label

from codelimit.common.report.Report import Report
from codelimit.common.report.ReportUnit import format_report_unit
from codelimit.common.source_utils import get_location_range
from codelimit.common.utils import format_unit
from codelimit.tui.CodeLimitAppHeader import CodeLimitAppHeader
from codelimit.tui.CodeScreen import CodeScreen

Expand All @@ -26,7 +26,14 @@ def compose(self) -> ComposeResult:
for idx, unit in enumerate(
self.report.all_report_units_sorted_by_length_asc()[:100]
):
list_view.append(ListItem(Label(format_report_unit(unit)), name=f"{idx}"))
list_view.append(
ListItem(
Label(
format_unit(unit.measurement.unit_name, unit.measurement.value)
),
name=f"{idx}",
)
)
yield list_view
self.set_focus(list_view)
self.install_screen(self.code_screen, "code_screen")
Expand Down
Loading

0 comments on commit 5dda87d

Please sign in to comment.