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

Feature/basic composer parsing #1

Merged
merged 14 commits into from
Oct 19, 2024
11 changes: 3 additions & 8 deletions .github/workflows/pylint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,12 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint
- name: Analysing the code with pylint
run: |
pylint $(git ls-files '*.py')
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
39 changes: 39 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace

- repo: https://github.com/psf/black
rev: 22.10.0
hooks:
- id: black
files: src/
types: [file, python]

- repo: https://github.com/myint/autoflake
rev: v2.3.1
hooks:
- id: autoflake
args:
- --in-place
- --imports=pydantic
files: src/
types: [file, python]

- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
- id: flake8
files: src/
types: [file, python]
args: [--max-line-length=131, --ignore, "F401"]

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.8.0
hooks:
- id: mypy
files: src/
types: [file, python]
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: console console-quiet .uv
.PHONY: console console-quiet .uv install pre-commit

.uv:
@if ! which uv > /dev/null 2>&1; then \
Expand All @@ -14,4 +14,10 @@ console: .uv

console-quiet: .uv
@uv run textual console -x EVENT
#@uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO
#@uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO

pre-commit: .uv install
@uv run pre-commit run --all-files

install: .uv
@uv run pre-commit install
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- composer install
- composer update
- composer outdated
- composer actions
-
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ dependencies = [

[tool.uv]
dev-dependencies = [
"pre-commit>=4.0.1",
"textual-dev>=1.6.1",
]
31 changes: 31 additions & 0 deletions src/composer_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import subprocess


def composer_updatable(path: str) -> dict[str, str]:
with subprocess.Popen(
["composer", "update", "--dry-run"],
cwd=path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
) as process:
stdout, stderr = process.communicate()
lines = stdout.strip().split("\n")
packages: dict[str, str] = {}

# Processing lines for packages
for line in lines:
if line.startswith(" - Upgrading"):
# Extract package name and target version
parts = line.split("(")
package_name = line.strip().split(" ")[2] # Get the package name
version_info = (
parts[1].strip().rstrip(")")
) # Get the version info (v2.2.9 => v2.3.0)
target_version = version_info.split("=>")[
-1
].strip() # Get the target version

# Append to the packages list as a dictionary
packages[package_name] = target_version
return packages
37 changes: 32 additions & 5 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
import typer
from pydantic_core._pydantic_core import ValidationError
from rich import print

from composer_utils import composer_updatable
from models import Project
from presentation import MainApp
from settings import settings

app = typer.Typer()


@app.command()
def tui() -> None:
print("Launch tui")
# app = LegbaApp()
# app.run()
def tui(project_path: str) -> None:
try:
project = Project(path=project_path)
print(f"Composer present: {project.composer}")
except ValidationError as e:
print("Validation error:", e)
exit(1)

print(f"Launch tui for {project.name} project")
app = MainApp(project)
app.run()


@app.command()
def debug(project_path: str) -> None:
try:
project = Project(path=project_path)
print(f"Composer present: {project.composer}")
except ValidationError as e:
print("Validation error:", e)
exit(1)

print(project.composer_json)
composer_updatable(project)


def main() -> None:
app(prog_name=settings.__app_name__)


if __name__ == "__main__":
main()
main()
1 change: 1 addition & 0 deletions src/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .project import Project
54 changes: 54 additions & 0 deletions src/models/composer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import json
import os

from pydantic import BaseModel, Field
from rich import print


class Composer(BaseModel):
project_path: str
type: str
license: str
minimum_stability: str = Field(str, alias="minimum-stability")
prefer_stable: bool = Field(False, alias="prefer-stable")
required_packages: dict[str, str]
required_packages_dev: dict[str, str]
locked_packages: dict[str, str]
locked_packages_dev: dict[str, str]
scripts: dict[str, str | list[str] | dict[str, str]]

@classmethod
def from_json(cls, project_path: str):
# json_path =
with open(os.path.join(project_path, "composer.json"), "r") as file:
data = json.load(file)
required_packages = data.pop("require", [])
required_packages_dev = data.pop("require-dev", [])

lock_file = os.path.join(project_path, "composer.lock")
if os.path.exists(lock_file):
with open(lock_file, "r") as file:
lock_data = json.load(file)
locked_packages = {
package["name"]: package["version"]
for package in lock_data.pop("packages", [])
}
locked_packages_dev = {
package["name"]: package["version"]
for package in lock_data.pop("packages-dev", [])
}

return cls(
project_path=project_path,
required_packages_dev=required_packages_dev,
required_packages=required_packages,
locked_packages=locked_packages,
locked_packages_dev=locked_packages_dev,
**data
)

@property
def manual_scripts(self) -> list[str]:
exclude = ("auto-scripts", "post-install-cmd", "post-update-cmd")
return list(filter(lambda x: x not in exclude, list(self.scripts.keys())))
# return list(self.scripts.keys())
36 changes: 36 additions & 0 deletions src/models/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import os
from functools import cached_property
from typing import Optional

from pydantic import BaseModel, Field, field_validator, model_validator

from models.composer import Composer


class Project(BaseModel):
path: str
composer: Optional[bool] = Field(default=False)

@cached_property
def name(self) -> str:
return os.path.basename(self.path)

@field_validator("path", mode="before")
def check_directory_exists(cls, v) -> str:
if not os.path.isdir(v):
raise ValueError(f"Provided path '{v}' is not a valid directory.")
return v

@model_validator(mode="after")
def check_composer_file(self):
# Check if the directory contains a composer.json file
composer_file = os.path.join(self.path, "composer.json")
if os.path.exists(composer_file):
self.composer = True
return self

@cached_property
def composer_json(self) -> Optional[Composer]:
if not self.composer:
return None
return Composer.from_json(self.path)
31 changes: 31 additions & 0 deletions src/presentation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, TabbedContent

from models import Project

from .composer import ComposerPan
from .docker import DockerPan
from .summary import ProjectSummaryPan


class MainApp(App):
"""A Textual app to manage stopwatches."""

TITLE = "DX Companion"
BINDINGS = [
("d", "toggle_dark", "Toggle dark mode"),
]
CSS_PATH = "../tcss/layout.tcss"
_project: Project

def __init__(self, project: Project):
self._project = project
super().__init__()

def compose(self) -> ComposeResult:
yield Header()
with TabbedContent(initial="summary-pan"):
yield ProjectSummaryPan(project=self._project)
yield ComposerPan(composer_dir=self._project.path)
yield DockerPan(project=self._project)
yield Footer()
1 change: 1 addition & 0 deletions src/presentation/component/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .terminal_modal import TerminalModal
70 changes: 70 additions & 0 deletions src/presentation/component/terminal_modal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import subprocess

from textual import on, work
from textual.app import ComposeResult
from textual.containers import Container, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Button, RichLog, Static


class TerminalModal(ModalScreen):
def __init__(
self, command: str | list[str], path: str, use_stderr: bool = False, **kwargs
):
super().__init__(**kwargs)
self.command = command
self.path = path
self.modal_title = f"Running: {" ".join(self.command)}"
self.use_stderr = use_stderr
self.terminal = RichLog(
id="terminal_command",
highlight=True,
markup=True,
classes="modal_container",
)

def compose(self) -> ComposeResult:
with Container(id="modal_container"):
with Horizontal(classes="modal_title"):
yield Static(self.modal_title)
yield self.terminal
with Horizontal(classes="button_container"):
yield Button("Close", id="modal_close")

def on_mount(self) -> None:
self._start()

@work(exclusive=True, thread=True)
async def _start(self) -> None:
self.terminal.write(f"Path: [bold blue]{self.path}[/bold blue]")
self.terminal.write(f"Command: [bold blue]{" ".join(self.command)}[/bold blue]")
self.terminal.write(
"----------------------------------------------------------------",
shrink=True,
)
self.log(f"Running: {self.command} in {self.path}")
with subprocess.Popen(
self.command,
cwd=self.path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
) as process:
stdout, stderr = process.communicate()
if stderr and self.use_stderr:
self.terminal.write(f"[italic]{stderr}[/italic]")
self.terminal.write(stdout)

self.terminal.write(
"----------------------------------------------------------------",
shrink=True,
)
self.terminal.write(f"Return code [bold]{process.returncode}[/bold]")
if process.returncode == 0:
self.terminal.write("[bold green]Completed![/bold green]")
else:
self.terminal.write("[bold red]Completed with errors![/bold red]")

@on(Button.Pressed, "#modal_close")
def on_close(self, event: Button.Pressed) -> None:
self.app.pop_screen()
3 changes: 3 additions & 0 deletions src/presentation/composer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .composer_packages_table import ComposerPackagesTable
from .composer_pan import ComposerPan
from .composer_script_button import ComposerScriptButton
Loading
Loading