From 00b6d731ae2f4112dda7bef43763f008595380ff Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 17:01:52 -0300 Subject: [PATCH 01/16] testing github pages workflow --- .github/workflows/hugo.yml | 58 ++- .gitignore | 156 ++++++-- .gitmodules | 4 +- .python-version | 1 + README.md | 9 + hugo/.hugo_build.lock | 0 {archetypes => hugo/archetypes}/default.md | 0 {config => hugo/config}/_default/hugo.toml | 0 .../config}/_default/languages.en.toml | 0 {config => hugo/config}/_default/markup.toml | 0 .../config}/_default/menus.en.toml | 0 {config => hugo/config}/_default/module.toml | 0 {config => hugo/config}/_default/params.toml | 0 hugo.toml => hugo/hugo.toml | 0 .../layouts}/partials/under-construction.html | 0 {layouts => hugo/layouts}/resources/list.html | 0 .../layouts}/resources/section.html | 0 .../layouts}/shortcodes/render-partial.html | 0 {layouts => hugo/layouts}/tags/list.html | 0 {layouts => hugo/layouts}/tags/term.html | 0 hugo/themes/blowfish | 1 + pyproject.toml | 111 ++++++ src/guhcampos/README.md | 120 ++++++ src/guhcampos/cli.py | 24 ++ src/guhcampos/core.py | 125 +++++++ src/guhcampos/hugo.py | 47 +++ src/guhcampos/letterboxd.py | 0 src/guhcampos/obsidian.py | 0 src/guhcampos/spotify.py | 1 + themes/blowfish | 1 - uv.lock | 346 ++++++++++++++++++ 31 files changed, 948 insertions(+), 56 deletions(-) create mode 100644 .python-version create mode 100644 README.md create mode 100644 hugo/.hugo_build.lock rename {archetypes => hugo/archetypes}/default.md (100%) rename {config => hugo/config}/_default/hugo.toml (100%) rename {config => hugo/config}/_default/languages.en.toml (100%) rename {config => hugo/config}/_default/markup.toml (100%) rename {config => hugo/config}/_default/menus.en.toml (100%) rename {config => hugo/config}/_default/module.toml (100%) rename {config => hugo/config}/_default/params.toml (100%) rename hugo.toml => hugo/hugo.toml (100%) rename {layouts => hugo/layouts}/partials/under-construction.html (100%) rename {layouts => hugo/layouts}/resources/list.html (100%) rename {layouts => hugo/layouts}/resources/section.html (100%) rename {layouts => hugo/layouts}/shortcodes/render-partial.html (100%) rename {layouts => hugo/layouts}/tags/list.html (100%) rename {layouts => hugo/layouts}/tags/term.html (100%) create mode 160000 hugo/themes/blowfish create mode 100644 pyproject.toml create mode 100644 src/guhcampos/README.md create mode 100644 src/guhcampos/cli.py create mode 100644 src/guhcampos/core.py create mode 100644 src/guhcampos/hugo.py create mode 100644 src/guhcampos/letterboxd.py create mode 100644 src/guhcampos/obsidian.py create mode 100644 src/guhcampos/spotify.py delete mode 160000 themes/blowfish create mode 100644 uv.lock diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index c3ab8c2..aca95a3 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -1,24 +1,17 @@ -name: Deploy Hugo site to Pages +name: build guhcampos.github.io on: push: branches: ["main"] - workflow_dispatch: - schedule: - - cron: '0 */6 * * *' # Run every 6 hours to sync new content -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read - pages: write id-token: write -# Allow only one concurrent deployment concurrency: group: "pages" cancel-in-progress: false -# Default to bash defaults: run: shell: bash @@ -27,48 +20,43 @@ jobs: build: runs-on: ubuntu-latest env: - HUGO_VERSION: 0.147.7 + HUGO_VERSION: 0.148.1 OBSIDIAN_VAULT: ${{ github.workspace }}/obsidian-vault HUGO_CONTENT: ${{ github.workspace }}/content steps: - - name: Checkout website repository + - name: checkout website repository uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 0 - - name: Checkout Obsidian vault + - name: checkout obsidian vault uses: actions/checkout@v4 with: - repository: guhcampos/guhcampos-obsidian + repository: ${{ secrets.OBSIDIAN_VAULT }} token: ${{ secrets.OBSIDIAN_PAT }} - path: obsidian-vault + path: build/obsidian-vault - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - cache: 'pip' + - name: install uv + uses: astral-sh/setup-uv@v6 - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install pyyaml + - name: install python + run: uv python install - - name: Setup Hugo + - name: install hugo uses: peaceiris/actions-hugo@v2 with: hugo-version: '0.147.7' extended: true - - name: Sync Obsidian content - run: python scripts/sync-obsidian + - name: install python dependencies + run: uv sync --locked --all-extras --dev + + - name: build website content + run: uv run build - - name: Setup Pages - id: pages - uses: actions/configure-pages@v4 - - name: Build with Hugo + - name: build hugo website env: HUGO_ENVIRONMENT: production HUGO_ENV: production @@ -77,19 +65,25 @@ jobs: --gc \ --minify \ --baseURL "${{ steps.pages.outputs.base_url }}/" + - name: setup Pages + id: pages + uses: actions/configure-pages@v4 - - name: Upload artifact + - name: upload artifacts uses: actions/upload-pages-artifact@v3 with: path: ./public deploy: + needs: build + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest - needs: build steps: - - name: Deploy to GitHub Pages + - name: deploy to github pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index b3fe63f..e560b69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,144 @@ -# Generated files by hugo -/public/ -/resources/_gen/ -/assets/jsconfig.json -hugo_stats.json -# Executable may be added to repository -hugo.exe -hugo.darwin -hugo.linux -# Temporary lock file while building -/.hugo_build.lock -# OS generated files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +__marimo__/ +__pycache__/ +__pypackages__/ +._* +.abstra/ +.cache +.coverage +.coverage.* +.dmypy.json .DS_Store .DS_Store? -._* +.eggs/ +.env +.envrc +.envrc +.hypothesis/ +.installed.cfg +.ipynb_checkpoints +.mypy_cache/ +.nox/ +.pdm-build/ +.pdm-python +.pixi +.pybuilder/ +.pypirc +.pyre/ +.pytest_cache/ +.Python +.pytype/ +.ropeproject +.ruff_cache/ +.scrapy .Spotlight-V100 +.spyderproject +.spyproject +.streamlit/secrets.toml +.tox/ .Trashes -ehthumbs.db -Thumbs.db - -# Content directory - generated from Obsidian +.venv +.vscode/ +.webassets-cache +*.cover +*.egg +*.egg-info/ +*.log +*.manifest +*.mo +*.pot +*.py.cover +*.py[codz] +*.sage.py +*.so +*.spec +**/__pycache__/ +*$py.class +/.hugo_build.lock +/assets/jsconfig.json /content/ - -# Log files /logs/ - -.envrc +/public/ +/resources/_gen/ +/site +build/ +celerybeat-schedule +celerybeat.pid +cover/ +coverage.xml +cython_debug/ +db.sqlite3 +db.sqlite3-journal +develop-eggs/ +dist/ +dmypy.json +docs/_build/ +downloads/ +eggs/ +ehthumbs.db +env.bak/ +env/ +ENV/ +htmlcov/ +hugo_stats.json +hugo.darwin +hugo.exe +hugo.linux +instance/ +ipython_config.py +lib/ +lib64/ +local_settings.py +MANIFEST +marimo/_lsp/ +marimo/_static/ +nosetests.xml +parts/ +pip-delete-this-directory.txt +pip-log.txt +profile_default/ +public/ +sdist/ +share/python-wheels/ +target/ +Thumbs.db +var/ +venv.bak/ +venv/ +wheels/ diff --git a/.gitmodules b/.gitmodules index 426d936..ba5bf93 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ -[submodule "themes/blowfish"] - path = themes/blowfish +[submodule "hugo/themes/blowfish"] + path = hugo/themes/blowfish url = https://github.com/nunocoracao/blowfish.git branch = main diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8734bee --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# guhcampos.github.io + +This is the sorce repository of my personal webpage, hosted on +https://guhcampos.github.io. As you see I decided to open source the whole +thing just to make the whole process public as I plan to add interesting stuff +here, such as fetching content remotely from multiple platforms I use on my +personal life. In the end, it doubles as an experimental platform for me to play +with a bunch of adjacent technologies, from some frontend stuff I'm not too +familiar with to the new AI tooling around. diff --git a/hugo/.hugo_build.lock b/hugo/.hugo_build.lock new file mode 100644 index 0000000..e69de29 diff --git a/archetypes/default.md b/hugo/archetypes/default.md similarity index 100% rename from archetypes/default.md rename to hugo/archetypes/default.md diff --git a/config/_default/hugo.toml b/hugo/config/_default/hugo.toml similarity index 100% rename from config/_default/hugo.toml rename to hugo/config/_default/hugo.toml diff --git a/config/_default/languages.en.toml b/hugo/config/_default/languages.en.toml similarity index 100% rename from config/_default/languages.en.toml rename to hugo/config/_default/languages.en.toml diff --git a/config/_default/markup.toml b/hugo/config/_default/markup.toml similarity index 100% rename from config/_default/markup.toml rename to hugo/config/_default/markup.toml diff --git a/config/_default/menus.en.toml b/hugo/config/_default/menus.en.toml similarity index 100% rename from config/_default/menus.en.toml rename to hugo/config/_default/menus.en.toml diff --git a/config/_default/module.toml b/hugo/config/_default/module.toml similarity index 100% rename from config/_default/module.toml rename to hugo/config/_default/module.toml diff --git a/config/_default/params.toml b/hugo/config/_default/params.toml similarity index 100% rename from config/_default/params.toml rename to hugo/config/_default/params.toml diff --git a/hugo.toml b/hugo/hugo.toml similarity index 100% rename from hugo.toml rename to hugo/hugo.toml diff --git a/layouts/partials/under-construction.html b/hugo/layouts/partials/under-construction.html similarity index 100% rename from layouts/partials/under-construction.html rename to hugo/layouts/partials/under-construction.html diff --git a/layouts/resources/list.html b/hugo/layouts/resources/list.html similarity index 100% rename from layouts/resources/list.html rename to hugo/layouts/resources/list.html diff --git a/layouts/resources/section.html b/hugo/layouts/resources/section.html similarity index 100% rename from layouts/resources/section.html rename to hugo/layouts/resources/section.html diff --git a/layouts/shortcodes/render-partial.html b/hugo/layouts/shortcodes/render-partial.html similarity index 100% rename from layouts/shortcodes/render-partial.html rename to hugo/layouts/shortcodes/render-partial.html diff --git a/layouts/tags/list.html b/hugo/layouts/tags/list.html similarity index 100% rename from layouts/tags/list.html rename to hugo/layouts/tags/list.html diff --git a/layouts/tags/term.html b/hugo/layouts/tags/term.html similarity index 100% rename from layouts/tags/term.html rename to hugo/layouts/tags/term.html diff --git a/hugo/themes/blowfish b/hugo/themes/blowfish new file mode 160000 index 0000000..0d485fc --- /dev/null +++ b/hugo/themes/blowfish @@ -0,0 +1 @@ +Subproject commit 0d485fcbf34acd8ff21a4b58fc20399b86b8c46d diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6eefb78 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,111 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "guhcampos" +version = "0.1.0" +description = "Build tools for guhcampos.github.io" +readme = "README.md" +requires-python = ">=3.12" +license = {text = "MIT"} +authors = [ + {name = "Gustavo Campos", email = "guhcampos@gmail.com"}, +] +keywords = ["hugo", "website", "build", "automation"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP :: Site Management", + "Topic :: Software Development :: Build Tools", +] + +dependencies = [ + "click>=8.1.0", + "rich>=13.0.0", + "pathspec>=0.11.0", + "watchdog>=3.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "black>=23.0.0", + "isort>=5.12.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[project.scripts] +build = "guhcampos.cli:build" + +[project.urls] +Homepage = "https://github.com/guhcampos/guhcampos.github.io" +Repository = "https://github.com/guhcampos/guhcampos.github.io" + +[tool.hatch.build.targets.wheel] +packages = ["src/guhcampos"] + +[tool.black] +line-length = 88 +target-version = ['py312'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["guhcampos"] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[tool.ruff] +target-version = "py312" +line-length = 88 +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.per-file-ignores] +"__init__.py" = ["F401"] diff --git a/src/guhcampos/README.md b/src/guhcampos/README.md new file mode 100644 index 0000000..a41ec27 --- /dev/null +++ b/src/guhcampos/README.md @@ -0,0 +1,120 @@ +# guhcampos + +Build tools for guhcampos.github.io - a Hugo-based personal website. + +## Features + +- **Hugo Build Management**: Streamlined Hugo site building with rich output +- **Development Server**: Easy Hugo development server management +- **Build Statistics**: Track build performance and output metrics +- **Rich CLI**: Beautiful command-line interface with progress indicators + +## Installation + +This package is part of the guhcampos.github.io repository and uses `uv` for dependency management. + +```bash +# Install dependencies +uv sync + +# Install the package in development mode +uv pip install -e . +``` + +## Usage + +### Building the Hugo Site + +```bash +# Basic build +build + +# Build with options +build --clean --minify --verbose + +# Build with drafts and future content +build --draft --future +``` + +### Development Server + +```bash +# Start development server +build serve + +# Custom port and binding +build serve --port 8080 --bind 0.0.0.0 + +# Include drafts +build serve --draft +``` + +### Command Options + +#### Build Command +- `--hugo-dir`: Hugo site directory (default: `hugo`) +- `--output-dir`: Output directory (default: `hugo/public`) +- `--clean`: Clean output directory before building +- `--draft`: Include draft content +- `--future`: Include future-dated content +- `--minify`: Minify the output +- `--verbose`: Enable verbose output + +#### Serve Command +- `--hugo-dir`: Hugo site directory (default: `hugo`) +- `--port`: Port to serve on (default: 1313) +- `--bind`: Address to bind to (default: 127.0.0.1) +- `--draft`: Include draft content +- `--future`: Include future-dated content + +## Development + +### Running Tests + +```bash +# Run all tests +uv run pytest + +# Run with coverage +uv run pytest --cov=guhcampos + +# Run specific test file +uv run pytest tests/test_core.py +``` + +### Code Quality + +```bash +# Format code +uv run black src/ tests/ + +# Sort imports +uv run isort src/ tests/ + +# Lint code +uv run ruff check src/ tests/ + +# Type checking +uv run mypy src/ +``` + +## Project Structure + +``` +src/guhcampos/ +├── __init__.py # Package initialization +├── cli.py # Click-based command-line interface +├── core.py # Core build utilities and Hugo integration +└── README.md # This file +``` + +## Dependencies + +- **click**: Command-line interface creation +- **rich**: Rich text and beautiful formatting in the terminal +- **pathspec**: Path pattern matching +- **watchdog**: File system monitoring (for future features) + +## License + +MIT License - see the main repository LICENSE file. diff --git a/src/guhcampos/cli.py b/src/guhcampos/cli.py new file mode 100644 index 0000000..cf8b079 --- /dev/null +++ b/src/guhcampos/cli.py @@ -0,0 +1,24 @@ +import click +from rich.console import Console + +from .hugo import build_hugo + +console = Console() +from pathlib import Path + +HUGO_DIR = Path(__file__).parent.parent.parent / "hugo" +PUBLIC_DIR = Path(__file__).parent.parent.parent / "public" + + +@click.group() +def cli() -> None: + pass + + +@cli.command() +def build() -> None: + build_hugo(src=HUGO_DIR, dst=PUBLIC_DIR) + + +if __name__ == "__main__": + cli() diff --git a/src/guhcampos/core.py b/src/guhcampos/core.py new file mode 100644 index 0000000..b32a0cf --- /dev/null +++ b/src/guhcampos/core.py @@ -0,0 +1,125 @@ +import subprocess +from pathlib import Path + +from rich.console import Console + +console = Console() + + +class HugoBuilder: + """Hugo site builder with rich output and error handling.""" + + def __init__(self, hugo_dir: Path, output_dir: Path): + self.hugo_dir = hugo_dir + self.output_dir = output_dir + + def validate_setup(self) -> bool: + """Validate that Hugo is properly set up.""" + if not self.hugo_dir.exists(): + console.print(f"[red]Error: Hugo directory '{self.hugo_dir}' does not exist[/red]") + return False + + if not (self.hugo_dir / "hugo.toml").exists(): + console.print(f"[red]Error: No hugo.toml found in '{self.hugo_dir}'[/red]") + return False + + return True + + def check_hugo_installed(self) -> bool: + """Check if Hugo is installed and available.""" + try: + result = subprocess.run( + ["hugo", "version"], + capture_output=True, + text=True, + check=True, + ) + console.print(f"[green]Found Hugo: {result.stdout.strip()}[/green]") + return True + except (subprocess.CalledProcessError, FileNotFoundError): + console.print("[red]Error: Hugo is not installed or not in PATH[/red]") + return False + + def build( + self, + draft: bool = False, + future: bool = False, + minify: bool = False, + verbose: bool = False, + ) -> bool: + """Build the Hugo site.""" + if not self.validate_setup(): + return False + + if not self.check_hugo_installed(): + return False + + # Prepare Hugo command + cmd = ["hugo"] + + if not draft: + cmd.append("--buildDrafts=false") + if not future: + cmd.append("--buildFuture=false") + if minify: + cmd.append("--minify") + if verbose: + cmd.append("--verbose") + + cmd.extend([ + "--source", str(self.hugo_dir), + "--destination", str(self.output_dir), + ]) + + # Execute build + try: + result = subprocess.run( + cmd, + cwd=self.hugo_dir, + capture_output=True, + text=True, + check=True, + ) + + if verbose and result.stdout: + console.print("\n[dim]Build output:[/dim]") + console.print(result.stdout) + + return True + + except subprocess.CalledProcessError as e: + console.print(f"\n[red]Error: Hugo build failed with exit code {e.returncode}[/red]") + if e.stdout: + console.print(f"\n[dim]stdout:[/dim]\n{e.stdout}") + if e.stderr: + console.print(f"\n[dim]stderr:[/dim]\n{e.stderr}") + return False + + def get_build_stats(self) -> dict | None: + """Get statistics about the built site.""" + if not self.output_dir.exists(): + return None + + total_files = sum(1 for _ in self.output_dir.rglob("*") if _.is_file()) + total_size = sum(f.stat().st_size for f in self.output_dir.rglob("*") if f.is_file()) + + return { + "files": total_files, + "size_bytes": total_size, + "size_mb": total_size / 1024 / 1024, + } + + +def get_project_root() -> Path: + """Get the project root directory.""" + return Path(__file__).parent.parent.parent + + +def get_hugo_dir() -> Path: + """Get the Hugo directory path.""" + return get_project_root() / "hugo" + + +def get_output_dir() -> Path: + """Get the default output directory path.""" + return get_hugo_dir() / "public" diff --git a/src/guhcampos/hugo.py b/src/guhcampos/hugo.py new file mode 100644 index 0000000..c0b36d1 --- /dev/null +++ b/src/guhcampos/hugo.py @@ -0,0 +1,47 @@ +import subprocess +import sys +from pathlib import Path + +from rich.console import Console +from rich.panel import Panel + +console = Console() + + +def build_hugo(src: Path, dst: Path): + cmd = [ + "hugo", + "--minify", + "--source", + str(src), + "--destination", + str(dst), + "--cleanDestinationDir", + ] + console.print(f"Command: {' '.join(cmd)}") + console.print( + Panel( + f"[bold]Building Hugo site[/bold]\nCommand: {' '.join(cmd)}", + title="Build Configuration", + border_style="blue", + ) + ) + + try: + subprocess.run( + cmd, + cwd=src, + capture_output=True, + text=True, + check=True, + ) + + except subprocess.CalledProcessError as e: + console.print( + f"\n[red]Error: Hugo build failed with exit code {e.returncode}[/red]" + ) + if e.stdout: + console.print(f"\n[dim]stdout:[/dim]\n{e.stdout}") + if e.stderr: + console.print(f"\n[dim]stderr:[/dim]\n{e.stderr}") + sys.exit(1) diff --git a/src/guhcampos/letterboxd.py b/src/guhcampos/letterboxd.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guhcampos/obsidian.py b/src/guhcampos/obsidian.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guhcampos/spotify.py b/src/guhcampos/spotify.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/guhcampos/spotify.py @@ -0,0 +1 @@ + diff --git a/themes/blowfish b/themes/blowfish deleted file mode 160000 index 0b06a64..0000000 --- a/themes/blowfish +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0b06a64139beba6287e7685f4c810ad4ff772fde diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6328e79 --- /dev/null +++ b/uv.lock @@ -0,0 +1,346 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b7/c0465ca253df10a9e8dae0692a4ae6e9726d245390aaef92360e1d6d3832/coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b", size = 813556, upload-time = "2025-07-03T10:54:15.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/d7/7deefc6fd4f0f1d4c58051f4004e366afc9e7ab60217ac393f247a1de70a/coverage-7.9.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae9eb07f1cfacd9cfe8eaee6f4ff4b8a289a668c39c165cd0c8548484920ffc0", size = 212344, upload-time = "2025-07-03T10:53:09.3Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/ee03c95d32be4d519e6a02e601267769ce2e9a91fc8faa1b540e3626c680/coverage-7.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ce85551f9a1119f02adc46d3014b5ee3f765deac166acf20dbb851ceb79b6f3", size = 212580, upload-time = "2025-07-03T10:53:11.52Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9f/826fa4b544b27620086211b87a52ca67592622e1f3af9e0a62c87aea153a/coverage-7.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8f6389ac977c5fb322e0e38885fbbf901743f79d47f50db706e7644dcdcb6e1", size = 246383, upload-time = "2025-07-03T10:53:13.134Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b3/4477aafe2a546427b58b9c540665feff874f4db651f4d3cb21b308b3a6d2/coverage-7.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff0d9eae8cdfcd58fe7893b88993723583a6ce4dfbfd9f29e001922544f95615", size = 243400, upload-time = "2025-07-03T10:53:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c2/efffa43778490c226d9d434827702f2dfbc8041d79101a795f11cbb2cf1e/coverage-7.9.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae939811e14e53ed8a9818dad51d434a41ee09df9305663735f2e2d2d7d959b", size = 245591, upload-time = "2025-07-03T10:53:15.872Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e7/a59888e882c9a5f0192d8627a30ae57910d5d449c80229b55e7643c078c4/coverage-7.9.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:31991156251ec202c798501e0a42bbdf2169dcb0f137b1f5c0f4267f3fc68ef9", size = 245402, upload-time = "2025-07-03T10:53:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/92/a5/72fcd653ae3d214927edc100ce67440ed8a0a1e3576b8d5e6d066ed239db/coverage-7.9.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d0d67963f9cbfc7c7f96d4ac74ed60ecbebd2ea6eeb51887af0f8dce205e545f", size = 243583, upload-time = "2025-07-03T10:53:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f5/84e70e4df28f4a131d580d7d510aa1ffd95037293da66fd20d446090a13b/coverage-7.9.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49b752a2858b10580969ec6af6f090a9a440a64a301ac1528d7ca5f7ed497f4d", size = 244815, upload-time = "2025-07-03T10:53:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/d73d7cbdbd09fdcf4642655ae843ad403d9cbda55d725721965f3580a314/coverage-7.9.2-cp312-cp312-win32.whl", hash = "sha256:88d7598b8ee130f32f8a43198ee02edd16d7f77692fa056cb779616bbea1b355", size = 214719, upload-time = "2025-07-03T10:53:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d6/7486dcc3474e2e6ad26a2af2db7e7c162ccd889c4c68fa14ea8ec189c9e9/coverage-7.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:9dfb070f830739ee49d7c83e4941cc767e503e4394fdecb3b54bfdac1d7662c0", size = 215509, upload-time = "2025-07-03T10:53:22.853Z" }, + { url = "https://files.pythonhosted.org/packages/b7/34/0439f1ae2593b0346164d907cdf96a529b40b7721a45fdcf8b03c95fcd90/coverage-7.9.2-cp312-cp312-win_arm64.whl", hash = "sha256:4e2c058aef613e79df00e86b6d42a641c877211384ce5bd07585ed7ba71ab31b", size = 213910, upload-time = "2025-07-03T10:53:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/94/9d/7a8edf7acbcaa5e5c489a646226bed9591ee1c5e6a84733c0140e9ce1ae1/coverage-7.9.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:985abe7f242e0d7bba228ab01070fde1d6c8fa12f142e43debe9ed1dde686038", size = 212367, upload-time = "2025-07-03T10:53:25.811Z" }, + { url = "https://files.pythonhosted.org/packages/e8/9e/5cd6f130150712301f7e40fb5865c1bc27b97689ec57297e568d972eec3c/coverage-7.9.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c3939264a76d44fde7f213924021ed31f55ef28111a19649fec90c0f109e6d", size = 212632, upload-time = "2025-07-03T10:53:27.075Z" }, + { url = "https://files.pythonhosted.org/packages/a8/de/6287a2c2036f9fd991c61cefa8c64e57390e30c894ad3aa52fac4c1e14a8/coverage-7.9.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae5d563e970dbe04382f736ec214ef48103d1b875967c89d83c6e3f21706d5b3", size = 245793, upload-time = "2025-07-03T10:53:28.408Z" }, + { url = "https://files.pythonhosted.org/packages/06/cc/9b5a9961d8160e3cb0b558c71f8051fe08aa2dd4b502ee937225da564ed1/coverage-7.9.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdd612e59baed2a93c8843c9a7cb902260f181370f1d772f4842987535071d14", size = 243006, upload-time = "2025-07-03T10:53:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/49/d9/4616b787d9f597d6443f5588619c1c9f659e1f5fc9eebf63699eb6d34b78/coverage-7.9.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:256ea87cb2a1ed992bcdfc349d8042dcea1b80436f4ddf6e246d6bee4b5d73b6", size = 244990, upload-time = "2025-07-03T10:53:31.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/83/801cdc10f137b2d02b005a761661649ffa60eb173dcdaeb77f571e4dc192/coverage-7.9.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f44ae036b63c8ea432f610534a2668b0c3aee810e7037ab9d8ff6883de480f5b", size = 245157, upload-time = "2025-07-03T10:53:32.717Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/41911ed7e9d3ceb0ffb019e7635468df7499f5cc3edca5f7dfc078e9c5ec/coverage-7.9.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82d76ad87c932935417a19b10cfe7abb15fd3f923cfe47dbdaa74ef4e503752d", size = 243128, upload-time = "2025-07-03T10:53:34.009Z" }, + { url = "https://files.pythonhosted.org/packages/10/41/344543b71d31ac9cb00a664d5d0c9ef134a0fe87cb7d8430003b20fa0b7d/coverage-7.9.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:619317bb86de4193debc712b9e59d5cffd91dc1d178627ab2a77b9870deb2868", size = 244511, upload-time = "2025-07-03T10:53:35.434Z" }, + { url = "https://files.pythonhosted.org/packages/d5/81/3b68c77e4812105e2a060f6946ba9e6f898ddcdc0d2bfc8b4b152a9ae522/coverage-7.9.2-cp313-cp313-win32.whl", hash = "sha256:0a07757de9feb1dfafd16ab651e0f628fd7ce551604d1bf23e47e1ddca93f08a", size = 214765, upload-time = "2025-07-03T10:53:36.787Z" }, + { url = "https://files.pythonhosted.org/packages/06/a2/7fac400f6a346bb1a4004eb2a76fbff0e242cd48926a2ce37a22a6a1d917/coverage-7.9.2-cp313-cp313-win_amd64.whl", hash = "sha256:115db3d1f4d3f35f5bb021e270edd85011934ff97c8797216b62f461dd69374b", size = 215536, upload-time = "2025-07-03T10:53:38.188Z" }, + { url = "https://files.pythonhosted.org/packages/08/47/2c6c215452b4f90d87017e61ea0fd9e0486bb734cb515e3de56e2c32075f/coverage-7.9.2-cp313-cp313-win_arm64.whl", hash = "sha256:48f82f889c80af8b2a7bb6e158d95a3fbec6a3453a1004d04e4f3b5945a02694", size = 213943, upload-time = "2025-07-03T10:53:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/e211e942b22d6af5e0f323faa8a9bc7c447a1cf1923b64c47523f36ed488/coverage-7.9.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:55a28954545f9d2f96870b40f6c3386a59ba8ed50caf2d949676dac3ecab99f5", size = 213088, upload-time = "2025-07-03T10:53:40.874Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2f/762551f97e124442eccd907bf8b0de54348635b8866a73567eb4e6417acf/coverage-7.9.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cdef6504637731a63c133bb2e6f0f0214e2748495ec15fe42d1e219d1b133f0b", size = 213298, upload-time = "2025-07-03T10:53:42.218Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b7/76d2d132b7baf7360ed69be0bcab968f151fa31abe6d067f0384439d9edb/coverage-7.9.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcd5ebe66c7a97273d5d2ddd4ad0ed2e706b39630ed4b53e713d360626c3dbb3", size = 256541, upload-time = "2025-07-03T10:53:43.823Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/392b219837d7ad47d8e5974ce5f8dc3deb9f99a53b3bd4d123602f960c81/coverage-7.9.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9303aed20872d7a3c9cb39c5d2b9bdbe44e3a9a1aecb52920f7e7495410dfab8", size = 252761, upload-time = "2025-07-03T10:53:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/d5/77/4256d3577fe1b0daa8d3836a1ebe68eaa07dd2cbaf20cf5ab1115d6949d4/coverage-7.9.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc18ea9e417a04d1920a9a76fe9ebd2f43ca505b81994598482f938d5c315f46", size = 254917, upload-time = "2025-07-03T10:53:46.931Z" }, + { url = "https://files.pythonhosted.org/packages/53/99/fc1a008eef1805e1ddb123cf17af864743354479ea5129a8f838c433cc2c/coverage-7.9.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6406cff19880aaaadc932152242523e892faff224da29e241ce2fca329866584", size = 256147, upload-time = "2025-07-03T10:53:48.289Z" }, + { url = "https://files.pythonhosted.org/packages/92/c0/f63bf667e18b7f88c2bdb3160870e277c4874ced87e21426128d70aa741f/coverage-7.9.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d0d4f6ecdf37fcc19c88fec3e2277d5dee740fb51ffdd69b9579b8c31e4232e", size = 254261, upload-time = "2025-07-03T10:53:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/8c/32/37dd1c42ce3016ff8ec9e4b607650d2e34845c0585d3518b2a93b4830c1a/coverage-7.9.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c33624f50cf8de418ab2b4d6ca9eda96dc45b2c4231336bac91454520e8d1fac", size = 255099, upload-time = "2025-07-03T10:53:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/da/2e/af6b86f7c95441ce82f035b3affe1cd147f727bbd92f563be35e2d585683/coverage-7.9.2-cp313-cp313t-win32.whl", hash = "sha256:1df6b76e737c6a92210eebcb2390af59a141f9e9430210595251fbaf02d46926", size = 215440, upload-time = "2025-07-03T10:53:52.808Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bb/8a785d91b308867f6b2e36e41c569b367c00b70c17f54b13ac29bcd2d8c8/coverage-7.9.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f5fd54310b92741ebe00d9c0d1d7b2b27463952c022da6d47c175d246a98d1bd", size = 216537, upload-time = "2025-07-03T10:53:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a0/a6bffb5e0f41a47279fd45a8f3155bf193f77990ae1c30f9c224b61cacb0/coverage-7.9.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c48c2375287108c887ee87d13b4070a381c6537d30e8487b24ec721bf2a781cb", size = 214398, upload-time = "2025-07-03T10:53:56.715Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/bbe2e63902847cf79036ecc75550d0698af31c91c7575352eb25190d0fb3/coverage-7.9.2-py3-none-any.whl", hash = "sha256:e425cd5b00f6fc0ed7cdbd766c70be8baab4b7839e4d4fe5fac48581dd968ea4", size = 204005, upload-time = "2025-07-03T10:54:13.491Z" }, +] + +[[package]] +name = "guhcampos" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "pathspec" }, + { name = "rich" }, + { name = "watchdog" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, + { name = "click", specifier = ">=8.1.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, + { name = "pathspec", specifier = ">=0.11.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "watchdog", specifier = ">=3.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" }, + { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" }, + { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, +] + +[[package]] +name = "ruff" +version = "0.12.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341, upload-time = "2025-07-11T13:21:16.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499, upload-time = "2025-07-11T13:20:26.321Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413, upload-time = "2025-07-11T13:20:30.017Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941, upload-time = "2025-07-11T13:20:33.046Z" }, + { url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001, upload-time = "2025-07-11T13:20:35.534Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641, upload-time = "2025-07-11T13:20:38.459Z" }, + { url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059, upload-time = "2025-07-11T13:20:41.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890, upload-time = "2025-07-11T13:20:44.442Z" }, + { url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008, upload-time = "2025-07-11T13:20:47.374Z" }, + { url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096, upload-time = "2025-07-11T13:20:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307, upload-time = "2025-07-11T13:20:52.945Z" }, + { url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020, upload-time = "2025-07-11T13:20:55.799Z" }, + { url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300, upload-time = "2025-07-11T13:20:58.222Z" }, + { url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119, upload-time = "2025-07-11T13:21:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990, upload-time = "2025-07-11T13:21:04.524Z" }, + { url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263, upload-time = "2025-07-11T13:21:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072, upload-time = "2025-07-11T13:21:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] From b6a6398ee721a9dc2625f4dd6540f085bc8d8e2b Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 17:03:17 -0300 Subject: [PATCH 02/16] testing github pages workflow --- .github/workflows/hugo.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index aca95a3..129368b 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -2,7 +2,6 @@ name: build guhcampos.github.io on: push: - branches: ["main"] permissions: contents: read @@ -76,6 +75,7 @@ jobs: deploy: needs: build + if: github.ref == 'refs/heads/main' permissions: pages: write id-token: write From 48ac4a68815fd8827d5012df0f2098839ece9ee8 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 17:10:55 -0300 Subject: [PATCH 03/16] testing github pages workflow --- .github/workflows/hugo.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index 129368b..fb1fa5e 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ secrets.OBSIDIAN_VAULT }} - token: ${{ secrets.OBSIDIAN_PAT }} + token: ${{ secrets.OBSIDIAN_DEPLOY_KEY }} path: build/obsidian-vault - name: install uv From 7e266458ac16bc4873fc5a02a304520e4ed1ae68 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 17:13:53 -0300 Subject: [PATCH 04/16] testing github pages workflow --- .github/workflows/hugo.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index fb1fa5e..f610497 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -33,8 +33,9 @@ jobs: uses: actions/checkout@v4 with: repository: ${{ secrets.OBSIDIAN_VAULT }} - token: ${{ secrets.OBSIDIAN_DEPLOY_KEY }} + ssh-key: ${{ secrets.OBSIDIAN_DEPLOY_KEY }} path: build/obsidian-vault + fetch-depth: 1 - name: install uv uses: astral-sh/setup-uv@v6 From 3f5484a00a16679f6403256c9aa71de47cc02740 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 17:22:43 -0300 Subject: [PATCH 05/16] testing github pages workflow --- .github/workflows/hugo.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index f610497..327697f 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -52,19 +52,9 @@ jobs: - name: install python dependencies run: uv sync --locked --all-extras --dev - - name: build website content + - name: build website run: uv run build - - - name: build hugo website - env: - HUGO_ENVIRONMENT: production - HUGO_ENV: production - run: | - hugo \ - --gc \ - --minify \ - --baseURL "${{ steps.pages.outputs.base_url }}/" - name: setup Pages id: pages uses: actions/configure-pages@v4 From e5824f4ed1364228da720834064fe63f93d5c907 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 17:31:57 -0300 Subject: [PATCH 06/16] updating pre-commit with useful hooks --- .pre-commit-config.yaml | 33 +++++++++++++++++++++++++++------ src/guhcampos/core.py | 24 +++++++++++++++++------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9d21b68..31e2405 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,28 @@ repos: -- repo: https://github.com/Yelp/detect-secrets - rev: v1.5.0 - hooks: - - id: detect-secrets - args: ['--baseline', '.secrets.baseline'] - exclude: package.lock.json|poetry.lock|yarn.lock + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.2 + hooks: + - id: gitleaks + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: check-added-large-files + - id: check-json + - id: check-shebang-scripts-are-executable + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: debug-statements + - id: detect-private-key + - id: pretty-format-json + args: ['--indent', '2', '--autofix'] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.3 + hooks: + - id: ruff-check + - id: ruff-format diff --git a/src/guhcampos/core.py b/src/guhcampos/core.py index b32a0cf..6c05758 100644 --- a/src/guhcampos/core.py +++ b/src/guhcampos/core.py @@ -16,7 +16,9 @@ def __init__(self, hugo_dir: Path, output_dir: Path): def validate_setup(self) -> bool: """Validate that Hugo is properly set up.""" if not self.hugo_dir.exists(): - console.print(f"[red]Error: Hugo directory '{self.hugo_dir}' does not exist[/red]") + console.print( + f"[red]Error: Hugo directory '{self.hugo_dir}' does not exist[/red]" + ) return False if not (self.hugo_dir / "hugo.toml").exists(): @@ -66,10 +68,14 @@ def build( if verbose: cmd.append("--verbose") - cmd.extend([ - "--source", str(self.hugo_dir), - "--destination", str(self.output_dir), - ]) + cmd.extend( + [ + "--source", + str(self.hugo_dir), + "--destination", + str(self.output_dir), + ] + ) # Execute build try: @@ -88,7 +94,9 @@ def build( return True except subprocess.CalledProcessError as e: - console.print(f"\n[red]Error: Hugo build failed with exit code {e.returncode}[/red]") + console.print( + f"\n[red]Error: Hugo build failed with exit code {e.returncode}[/red]" + ) if e.stdout: console.print(f"\n[dim]stdout:[/dim]\n{e.stdout}") if e.stderr: @@ -101,7 +109,9 @@ def get_build_stats(self) -> dict | None: return None total_files = sum(1 for _ in self.output_dir.rglob("*") if _.is_file()) - total_size = sum(f.stat().st_size for f in self.output_dir.rglob("*") if f.is_file()) + total_size = sum( + f.stat().st_size for f in self.output_dir.rglob("*") if f.is_file() + ) return { "files": total_files, From cfa4ea59e18fe59e39b581360abae167f1ba2d5f Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 19:54:05 -0300 Subject: [PATCH 07/16] fetch spotify playlists into the build directory for adding to hugo --- build/.gitkeep | 0 pyproject.toml | 12 +- src/guhcampos/README.md | 120 -------------- src/guhcampos/cli.py | 62 ++++++- src/guhcampos/settings.py | 24 +++ src/guhcampos/spotify.py | 1 - src/guhcampos/spotify/client.py | 236 +++++++++++++++++++++++++++ src/guhcampos/spotify/models.py | 48 ++++++ {scripts => src}/sync-obsidian | 0 tests/test_spotify.py | 275 ++++++++++++++++++++++++++++++++ uv.lock | 215 +++++++++++++++++++++++++ 11 files changed, 861 insertions(+), 132 deletions(-) create mode 100644 build/.gitkeep delete mode 100644 src/guhcampos/README.md create mode 100644 src/guhcampos/settings.py delete mode 100644 src/guhcampos/spotify.py create mode 100644 src/guhcampos/spotify/client.py create mode 100644 src/guhcampos/spotify/models.py rename {scripts => src}/sync-obsidian (100%) create mode 100644 tests/test_spotify.py diff --git a/build/.gitkeep b/build/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 6eefb78..3bee10c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,10 @@ dependencies = [ "rich>=13.0.0", "pathspec>=0.11.0", "watchdog>=3.0.0", + "requests>=2.31.0", + "pydantic-settings>=2.10.1", + "python-slugify>=8.0.4", + "pydantic>=2.11.7", ] [project.optional-dependencies] @@ -41,7 +45,7 @@ dev = [ ] [project.scripts] -build = "guhcampos.cli:build" +guhcampos = "guhcampos.cli:guhcampos" [project.urls] Homepage = "https://github.com/guhcampos/guhcampos.github.io" @@ -107,5 +111,7 @@ ignore = [ "C901", # too complex ] -[tool.ruff.per-file-ignores] -"__init__.py" = ["F401"] +[dependency-groups] +dev = [ + "pytest>=8.4.1", +] diff --git a/src/guhcampos/README.md b/src/guhcampos/README.md deleted file mode 100644 index a41ec27..0000000 --- a/src/guhcampos/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# guhcampos - -Build tools for guhcampos.github.io - a Hugo-based personal website. - -## Features - -- **Hugo Build Management**: Streamlined Hugo site building with rich output -- **Development Server**: Easy Hugo development server management -- **Build Statistics**: Track build performance and output metrics -- **Rich CLI**: Beautiful command-line interface with progress indicators - -## Installation - -This package is part of the guhcampos.github.io repository and uses `uv` for dependency management. - -```bash -# Install dependencies -uv sync - -# Install the package in development mode -uv pip install -e . -``` - -## Usage - -### Building the Hugo Site - -```bash -# Basic build -build - -# Build with options -build --clean --minify --verbose - -# Build with drafts and future content -build --draft --future -``` - -### Development Server - -```bash -# Start development server -build serve - -# Custom port and binding -build serve --port 8080 --bind 0.0.0.0 - -# Include drafts -build serve --draft -``` - -### Command Options - -#### Build Command -- `--hugo-dir`: Hugo site directory (default: `hugo`) -- `--output-dir`: Output directory (default: `hugo/public`) -- `--clean`: Clean output directory before building -- `--draft`: Include draft content -- `--future`: Include future-dated content -- `--minify`: Minify the output -- `--verbose`: Enable verbose output - -#### Serve Command -- `--hugo-dir`: Hugo site directory (default: `hugo`) -- `--port`: Port to serve on (default: 1313) -- `--bind`: Address to bind to (default: 127.0.0.1) -- `--draft`: Include draft content -- `--future`: Include future-dated content - -## Development - -### Running Tests - -```bash -# Run all tests -uv run pytest - -# Run with coverage -uv run pytest --cov=guhcampos - -# Run specific test file -uv run pytest tests/test_core.py -``` - -### Code Quality - -```bash -# Format code -uv run black src/ tests/ - -# Sort imports -uv run isort src/ tests/ - -# Lint code -uv run ruff check src/ tests/ - -# Type checking -uv run mypy src/ -``` - -## Project Structure - -``` -src/guhcampos/ -├── __init__.py # Package initialization -├── cli.py # Click-based command-line interface -├── core.py # Core build utilities and Hugo integration -└── README.md # This file -``` - -## Dependencies - -- **click**: Command-line interface creation -- **rich**: Rich text and beautiful formatting in the terminal -- **pathspec**: Path pattern matching -- **watchdog**: File system monitoring (for future features) - -## License - -MIT License - see the main repository LICENSE file. diff --git a/src/guhcampos/cli.py b/src/guhcampos/cli.py index cf8b079..8c0e710 100644 --- a/src/guhcampos/cli.py +++ b/src/guhcampos/cli.py @@ -2,23 +2,69 @@ from rich.console import Console from .hugo import build_hugo +from .settings import settings +from .spotify.client import ( + fetch_spotify_playlists, + list_spotify_playlists, +) console = Console() -from pathlib import Path - -HUGO_DIR = Path(__file__).parent.parent.parent / "hugo" -PUBLIC_DIR = Path(__file__).parent.parent.parent / "public" @click.group() -def cli() -> None: +def guhcampos() -> None: + pass + + +@guhcampos.group() +def hugo(): pass -@cli.command() +@hugo.command() def build() -> None: - build_hugo(src=HUGO_DIR, dst=PUBLIC_DIR) + build_hugo(src=settings.hugo_src_dir, dst=settings.hugo_dst_dir) + + +@guhcampos.group() +def spotify() -> None: + pass + + +@spotify.command() +def fetch_playlists() -> None: + """ + Fetch playlists from Spotify and save them to the output directory as both + M3U and JSON files. + """ + playlists = settings.spotify_playlists + + success = fetch_spotify_playlists( + playlists, + settings.spotify_playlists_output_dir, + settings.spotify_user_id, + settings.spotify_client_id, + settings.spotify_client_secret, + ) + + if not success: + raise click.ClickException("Failed to fetch playlists") + + +@spotify.command() +def list_playlists() -> None: + """ + List all playlists for the configured user. + """ + success = list_spotify_playlists( + settings.spotify_user_id, + settings.spotify_client_id, + settings.spotify_client_secret, + ) + + if not success: + raise click.ClickException("Failed to list playlists") if __name__ == "__main__": - cli() + guhcampos() diff --git a/src/guhcampos/settings.py b/src/guhcampos/settings.py new file mode 100644 index 0000000..8a31595 --- /dev/null +++ b/src/guhcampos/settings.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +REPO_ROOT = Path(__file__).parent.parent.parent + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="GUHCAMPOS_") + + hugo_src_dir: Path = REPO_ROOT / "hugo" + hugo_dst_dir: Path = REPO_ROOT / "public" + + spotify_client_id: str + spotify_client_secret: str + spotify_user_id: str + spotify_playlists: list[str] = [ + "This is Guhcampos", + "01.01 Beard Growing", + ] + spotify_playlists_output_dir: Path = REPO_ROOT / "build" / "spotify-playlists" + + +settings = Settings() diff --git a/src/guhcampos/spotify.py b/src/guhcampos/spotify.py deleted file mode 100644 index 8b13789..0000000 --- a/src/guhcampos/spotify.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/guhcampos/spotify/client.py b/src/guhcampos/spotify/client.py new file mode 100644 index 0000000..54d7550 --- /dev/null +++ b/src/guhcampos/spotify/client.py @@ -0,0 +1,236 @@ +import base64 +from collections.abc import Generator +from pathlib import Path + +import requests +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn + +console = Console() + +from .models import SpotifyPlaylist, SpotifyTrack + + +class SpotifyAPI: + def __init__(self, client_id: str, client_secret: str): + self.client_id = client_id + self.client_secret = client_secret + self.access_token = None + self.base_url = "https://api.spotify.com/v1" + + def authenticate(self) -> bool: + def _get_basic_auth() -> str: + credentials = f"{self.client_id}:{self.client_secret}" + return base64.b64encode(credentials.encode()).decode() + + auth_url = "https://accounts.spotify.com/api/token" + auth_data = {"grant_type": "client_credentials"} + auth_headers = {"Authorization": f"Basic {_get_basic_auth()}"} + + console.print("[yellow]Authenticating with Spotify...[/yellow]") + + try: + response = requests.post(auth_url, data=auth_data, headers=auth_headers) + response.raise_for_status() + + self.access_token = response.json()["access_token"] + + if not self.access_token: + raise Exception("Authentication failed") + + console.print("[green]Authentication successful![/green]") + return True + + except requests.RequestException as e: + console.print(f"[red]Authentication failed: {e}[/red]") + raise + + def fetch_playlists(self, user_id: str) -> Generator[SpotifyPlaylist, None, None]: + """ + Yield all playlists for a given user. + """ + headers = {"Authorization": f"Bearer {self.access_token}"} + limit = 50 # Maximum allowed by Spotify API + offset = 0 + + while True: + params = {"limit": limit, "offset": offset} + + try: + response = requests.get( + f"{self.base_url}/users/{user_id}/playlists", + headers=headers, + params=params, + ) + response.raise_for_status() + + data = response.json() + playlists = data.get("items", []) + + if not playlists: + break + + yield from [ + SpotifyPlaylist( + description=playlist["description"], + owner=playlist["owner"]["display_name"], + url=playlist["external_urls"]["spotify"], + id=playlist["id"], + name=playlist["name"], + tracks=[], # playlist["tracks"], + ) + for playlist in playlists + ] + + if len(playlists) < limit: + break + + offset += limit + + except requests.RequestException as e: + console.print(f"[red]Failed to fetch playlists: {e}[/red]") + break + + def get_playlist_tracks(self, playlist_id: str) -> list[SpotifyTrack]: + """ + Get all tracks from a playlist. + """ + + headers = {"Authorization": f"Bearer {self.access_token}"} + tracks = [] + url = f"{self.base_url}/playlists/{playlist_id}/tracks" + + while url: + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + + data = response.json() + tracks.extend(data.get("items", [])) + + url = data.get("next") + + except requests.RequestException as e: + console.print(f"[red]Failed to fetch tracks: {e}[/red]") + break + + tracks.sort(key=lambda x: x["track"]["name"]) + tracks.sort(key=lambda x: x["track"]["artists"][0]["name"]) + + return [ + SpotifyTrack( + name=track["track"]["name"], + artists=[artist["name"] for artist in track["track"]["artists"]], + album=track["track"]["album"]["name"], + duration_ms=track["track"]["duration_ms"], + url=track["track"]["external_urls"]["spotify"], + ) + for track in tracks + ] + + +def fetch_spotify_playlists( + playlists_filter: list[str], + output_dir: Path, + user_id: str, + client_id: str, + client_secret: str, +): + """ + Fetch all Spotify playlists for a user and save them as M3U files. + + Args: + user_id: Spotify user ID + output_dir: Directory to save M3U files + client_id: Spotify client ID + client_secret: Spotify client secret + + Returns: + True if successful, False otherwise + """ + spotify = SpotifyAPI(client_id, client_secret) + spotify.authenticate() + + output_dir.mkdir(parents=True, exist_ok=True) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + for playlist in spotify.fetch_playlists(user_id): + if playlist.name in playlists_filter: + task = progress.add_task(f"Processing '{playlist.name}'...", total=None) + + playlist.tracks = spotify.get_playlist_tracks(playlist.id) + + progress.update( + task, + description=f"[green]Processed '{playlist.name}' ({len(playlist.tracks)} tracks)[/green]", + ) + + with open( + output_dir / f"{playlist.slug}.m3u", "w", encoding="utf-8" + ) as f: + f.write(playlist.to_m3u()) + + with open( + output_dir / f"{playlist.slug}.json", "w", encoding="utf-8" + ) as f: + f.write(playlist.to_json()) + + return True + + +def list_spotify_playlists( + user_id: str, + client_id: str, + client_secret: str, +): + """ + List all playlists for a user using pagination. + + Args: + user_id: Spotify user ID + client_id: Spotify client ID + client_secret: Spotify client secret + + Returns: + True if successful, False otherwise + """ + + spotify = SpotifyAPI(client_id, client_secret) + spotify.authenticate() + + playlists = [] + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("Fetching playlists...", total=None) + + try: + for playlist in spotify.fetch_playlists(user_id): + playlists.append(playlist) + progress.update( + task, description=f"Found {len(playlists)} playlists..." + ) + + except Exception as e: + progress.update(task, description=f"[red]Error: {e}[/red]") + return False + console.print( + Panel( + f"[bold green]Found {len(playlists)} playlists[/bold green]\n\n" + + "\n".join( + [f"• {p.name} ({len(p.tracks)} tracks) - {p.url})" for p in playlists] + ), + title="📋 All Playlists", + border_style="blue", + ) + ) + + return True diff --git a/src/guhcampos/spotify/models.py b/src/guhcampos/spotify/models.py new file mode 100644 index 0000000..413db19 --- /dev/null +++ b/src/guhcampos/spotify/models.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel, HttpUrl +from slugify import slugify + + +class SpotifyTrack(BaseModel): + name: str + artists: list[str] + album: str + duration_ms: int + url: HttpUrl + + @property + def duration(self) -> int: + return self.duration_ms // 1000 + + +class SpotifyPlaylist(BaseModel): + id: str + name: str + tracks: list[SpotifyTrack] + url: HttpUrl + description: str + owner: str + + @property + def slug(self) -> str: + return slugify(self.name) + + def to_m3u(self) -> str: + output = [ + "#EXTM3U", + f"# {self.name}", + f"# Description:{self.description}", + f"# Created by: {self.owner}", + f"# Tracks: {len(self.tracks)}", + f"# Spotify URL: {self.url}", + ] + + for track in self.tracks: + artists_str = ", ".join(track.artists) + output.append( + f"#EXTINF:{track.duration},{artists_str} - {track.album} - {track.name}" + ) + output.append(track.url.encoded_string()) + return "\n".join(output) + + def to_json(self) -> str: + return self.model_dump_json() diff --git a/scripts/sync-obsidian b/src/sync-obsidian similarity index 100% rename from scripts/sync-obsidian rename to src/sync-obsidian diff --git a/tests/test_spotify.py b/tests/test_spotify.py new file mode 100644 index 0000000..e90eeae --- /dev/null +++ b/tests/test_spotify.py @@ -0,0 +1,275 @@ +"""Tests for the Spotify module.""" + +from unittest.mock import Mock, patch + +import pytest + +from guhcampos.spotify.client import ( + SpotifyAPI, + fetch_spotify_playlists, + list_spotify_playlists, +) +from guhcampos.spotify.models import SpotifyPlaylist, SpotifyTrack + + +def test_spotify_api_initialization(): + """Test SpotifyAPI initialization.""" + api = SpotifyAPI("test_client_id", "test_client_secret") + assert api.client_id == "test_client_id" + assert api.client_secret == "test_client_secret" + assert api.access_token is None + assert api.base_url == "https://api.spotify.com/v1" + + +@patch("guhcampos.spotify.client.requests.post") +def test_spotify_api_authenticate_success(mock_post): + """Test successful authentication.""" + mock_response = Mock() + mock_response.json.return_value = {"access_token": "test_token"} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + api = SpotifyAPI("test_client_id", "test_client_secret") + result = api.authenticate() + + assert result is True + assert api.access_token == "test_token" + + +@patch("guhcampos.spotify.client.requests.post") +def test_spotify_api_authenticate_failure(mock_post): + """Test failed authentication.""" + mock_post.side_effect = Exception("Auth failed") + + api = SpotifyAPI("test_client_id", "test_client_secret") + + with pytest.raises(Exception): + api.authenticate() + + +@patch("guhcampos.spotify.client.requests.get") +def test_fetch_playlists_pagination(mock_get): + """Test fetch_playlists with pagination.""" + + def mock_get_side_effect(*args, **kwargs): + mock_response = Mock() + mock_response.raise_for_status.return_value = None + + # Check the offset parameter to determine which page to return + params = kwargs.get("params", {}) + offset = params.get("offset", 0) + + print(f"DEBUG: Mock called with offset={offset}, params={params}") + + if offset == 0: + # First page: 50 items (full page, should continue) + print("DEBUG: Returning first page with 50 items") + mock_response.json.return_value = { + "items": [ + { + "id": str(i), + "name": f"Playlist {i}", + "description": f"Test playlist {i}", + "owner": {"display_name": "Test User"}, + "external_urls": { + "spotify": f"https://open.spotify.com/playlist/{i}" + }, + } + for i in range(1, 51) # 50 items + ] + } + elif offset == 50: + # Second page: 3 items (partial page, should stop) + print("DEBUG: Returning second page with 3 items") + mock_response.json.return_value = { + "items": [ + { + "id": str(i), + "name": f"Playlist {i}", + "description": f"Test playlist {i}", + "owner": {"display_name": "Test User"}, + "external_urls": { + "spotify": f"https://open.spotify.com/playlist/{i}" + }, + } + for i in range(51, 54) # 3 items + ] + } + else: + # Any other offset: empty + print(f"DEBUG: Returning empty page for offset {offset}") + mock_response.json.return_value = {"items": []} + + return mock_response + + mock_get.side_effect = mock_get_side_effect + + api = SpotifyAPI("test_client_id", "test_client_secret") + api.access_token = "test_token" + + playlists = list(api.fetch_playlists("test_user")) + + assert len(playlists) == 53 # 50 from first page + 3 from second page + assert playlists[0].name == "Playlist 1" + assert playlists[49].name == "Playlist 50" # Last item from first page + assert playlists[50].name == "Playlist 51" # First item from second page + assert playlists[52].name == "Playlist 53" # Last item from second page + assert all(isinstance(p, SpotifyPlaylist) for p in playlists) + + +@patch("guhcampos.spotify.client.requests.get") +def test_get_playlist_tracks(mock_get): + """Test get_playlist_tracks method.""" + mock_response = Mock() + mock_response.json.return_value = { + "items": [ + { + "track": { + "name": "Test Song", + "artists": [{"name": "Test Artist"}], + "album": {"name": "Test Album"}, + "duration_ms": 180000, + "external_urls": {"spotify": "https://open.spotify.com/track/test"}, + } + } + ] + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + api = SpotifyAPI("test_client_id", "test_client_secret") + api.access_token = "test_token" + + tracks = api.get_playlist_tracks("test_playlist_id") + + assert len(tracks) == 1 + assert isinstance(tracks[0], SpotifyTrack) + assert tracks[0].name == "Test Song" + assert tracks[0].artists == ["Test Artist"] + assert tracks[0].album == "Test Album" + assert tracks[0].duration_ms == 180000 + + +def test_spotify_track_model(): + """Test SpotifyTrack model.""" + track = SpotifyTrack( + name="Test Song", + artists=["Test Artist"], + album="Test Album", + duration_ms=180000, + url="https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh", + ) + + assert track.name == "Test Song" + assert track.artists == ["Test Artist"] + assert track.album == "Test Album" + assert track.duration_ms == 180000 + assert track.duration == 180 # duration in seconds + + +def test_spotify_playlist_model(): + """Test SpotifyPlaylist model.""" + playlist = SpotifyPlaylist( + id="test_id", + name="Test Playlist", + tracks=[], + url="https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", + description="Test description", + owner="Test User", + ) + + assert playlist.id == "test_id" + assert playlist.name == "Test Playlist" + assert playlist.description == "Test description" + assert playlist.owner == "Test User" + assert playlist.slug == "test-playlist" # slugified name + + +def test_playlist_to_m3u(): + """Test playlist to_m3u method.""" + track = SpotifyTrack( + name="Test Song", + artists=["Test Artist"], + album="Test Album", + duration_ms=180000, + url="https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh", + ) + + playlist = SpotifyPlaylist( + id="test_id", + name="Test Playlist", + tracks=[track], + url="https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", + description="Test description", + owner="Test User", + ) + + m3u_content = playlist.to_m3u() + + assert "#EXTM3U" in m3u_content + assert "Test Playlist" in m3u_content + assert "Test User" in m3u_content + assert "#EXTINF:180,Test Artist - Test Album - Test Song" in m3u_content + assert "https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh" in m3u_content + + +@patch("guhcampos.spotify.client.SpotifyAPI") +def test_fetch_spotify_playlists_success(mock_spotify_api, tmp_path): + """Test fetch_spotify_playlists with successful API calls.""" + mock_api_instance = Mock() + mock_api_instance.authenticate.return_value = True + + # Mock playlist + mock_playlist = SpotifyPlaylist( + id="1", + name="Test Playlist", + tracks=[], + url="https://open.spotify.com/playlist/1", + description="Test description", + owner="Test User", + ) + + mock_api_instance.fetch_playlists.return_value = [mock_playlist] + mock_api_instance.get_playlist_tracks.return_value = [] + mock_spotify_api.return_value = mock_api_instance + + result = fetch_spotify_playlists( + ["Test Playlist"], + tmp_path, # Use pytest's tmp_path fixture + "test_user_id", + "test_client_id", + "test_client_secret", + ) + + assert result is True + mock_api_instance.authenticate.assert_called_once() + mock_api_instance.fetch_playlists.assert_called_once_with("test_user_id") + + +@patch("guhcampos.spotify.client.SpotifyAPI") +def test_list_spotify_playlists_success(mock_spotify_api): + """Test list_spotify_playlists with successful API calls.""" + mock_api_instance = Mock() + mock_api_instance.authenticate.return_value = True + + mock_playlist = SpotifyPlaylist( + id="1", + name="Test Playlist", + tracks=[], + url="https://open.spotify.com/playlist/1", + description="Test description", + owner="Test User", + ) + + mock_api_instance.fetch_playlists.return_value = [mock_playlist] + mock_spotify_api.return_value = mock_api_instance + + result = list_spotify_playlists( + "test_user", + "test_client_id", + "test_client_secret", + ) + + assert result is True + mock_api_instance.authenticate.assert_called_once() + mock_api_instance.fetch_playlists.assert_called_once_with("test_user") diff --git a/uv.lock b/uv.lock index 6328e79..3f290be 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 2 requires-python = ">=3.12" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "black" version = "25.1.0" @@ -26,6 +35,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, ] +[[package]] +name = "certifi" +version = "2025.7.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -96,6 +149,10 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "pathspec" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-slugify" }, + { name = "requests" }, { name = "rich" }, { name = "watchdog" }, ] @@ -110,6 +167,11 @@ dev = [ { name = "ruff" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, @@ -117,14 +179,30 @@ requires-dist = [ { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pathspec", specifier = ">=0.11.0" }, + { name = "pydantic", specifier = ">=2.11.7" }, + { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "python-slugify", specifier = ">=8.0.4" }, + { name = "requests", specifier = ">=2.31.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, { name = "watchdog", specifier = ">=3.0.0" }, ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.4.1" }] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -235,6 +313,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -274,6 +423,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, +] + [[package]] name = "rich" version = "14.0.0" @@ -312,6 +497,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" }, ] +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1" @@ -321,6 +515,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From 67574ab00ec6a750f22aec7a3ac6c038c44a48f9 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 20:04:37 -0300 Subject: [PATCH 08/16] adding cursor rules --- .cursor/rules/cli-development.mdc | 58 +++++++++++++++++++++++++++ .cursor/rules/hugo-development.mdc | 42 +++++++++++++++++++ .cursor/rules/project-structure.mdc | 35 ++++++++++++++++ .cursor/rules/python-development.mdc | 43 ++++++++++++++++++++ .cursor/rules/spotify-integration.mdc | 46 +++++++++++++++++++++ .cursor/rules/testing-standards.mdc | 53 ++++++++++++++++++++++++ README.md | 7 +++- 7 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/cli-development.mdc create mode 100644 .cursor/rules/hugo-development.mdc create mode 100644 .cursor/rules/project-structure.mdc create mode 100644 .cursor/rules/python-development.mdc create mode 100644 .cursor/rules/spotify-integration.mdc create mode 100644 .cursor/rules/testing-standards.mdc diff --git a/.cursor/rules/cli-development.mdc b/.cursor/rules/cli-development.mdc new file mode 100644 index 0000000..b2b3b76 --- /dev/null +++ b/.cursor/rules/cli-development.mdc @@ -0,0 +1,58 @@ +# CLI Development Guide + +## CLI Structure +- Main CLI entry point: [src/guhcampos/cli.py](mdc:src/guhcampos/cli.py) +- Uses Click framework for command-line interface +- Entry point defined in [pyproject.toml](mdc:pyproject.toml) as `guhcampos` + +## Command Organization +- Main group: `guhcampos` with subcommands +- Keep CLI logic minimal, delegate to business logic + +## CLI Patterns +- Use Click groups for command organization +- Import pre-baked functions, not direct class instantiation +- Use Rich for console output and progress bars +- Handle errors gracefully with user-friendly messages + +## Command Structure +```python +@guhcampos.command() +def build(): + """Build the Hugo site""" + # Minimal CLI logic + # Call business logic functions +``` + +## Error Handling +- Catch exceptions and provide helpful error messages +- Use Rich console for colored output +- Validate inputs before processing +- Provide usage examples in docstrings + +## Configuration +- Use environment variables for sensitive data +- Load configuration from [src/guhcampos/settings.py](mdc:src/guhcampos/settings.py) +- Support both required and optional parameters +- Use Click options for configuration + +## User Experience +- Provide progress feedback for long-running operations +- Use descriptive command and option names +- Include help text for all commands +- Support both interactive and non-interactive modes + +## Testing CLI +- Test CLI commands with mocked dependencies +- Verify correct function calls and parameters +- Test error handling and user feedback +- Use Click's testing utilities when needed + +## Key Commands +- `guhcampos build` - Build Hugo site +- `guhcampos spotify list-playlists` - List Spotify playlists +- `guhcampos spotify fetch-playlists` - Fetch playlists into M3U and JSON files +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/hugo-development.mdc b/.cursor/rules/hugo-development.mdc new file mode 100644 index 0000000..813b01f --- /dev/null +++ b/.cursor/rules/hugo-development.mdc @@ -0,0 +1,42 @@ +# Hugo Development Guide + +## Hugo Site Structure +- All Hugo content is in [hugo/](mdc:hugo/) directory +- Configuration in [hugo/hugo.toml](mdc:hugo/hugo.toml) +- Theme is managed as git submodule in [hugo/themes/](mdc:hugo/themes/) +- Layouts in [hugo/layouts/](mdc:hugo/layouts/) +- Content in [hugo/content/](mdc:hugo/content/) (if exists) + +## Build Process +- Use Python CLI: `uv run guhcampos build` to build Hugo site +- Use Python CLI: `uv run guhcampos serve` to serve locally +- Build output goes to [hugo/public/](mdc:hugo/public/) +- Generated content (Spotify playlists) goes to [build/](mdc:build/) + +## Configuration +- Hugo configuration in [hugo/config/_default/](mdc:hugo/config/_default/) +- Environment-specific configs in [hugo/config/](mdc:hugo/config/) +- Theme configuration in [hugo/themes/blowfish/](mdc:hugo/themes/blowfish/) + +## Content Integration +- Spotify playlists are fetched and saved as M3U files +- Content can be generated from external APIs +- Use Hugo shortcodes for dynamic content +- Static assets in [hugo/static/](mdc:hugo/static/) (if exists) + +## Development Workflow +1. Edit Hugo content in [hugo/](mdc:hugo/) directory +2. Use Python tools to fetch external content +3. Build with `uv run guhcampos build` +4. Test locally with `uv run guhcampos serve` +5. Deploy via GitHub Actions + +## Key Files +- [hugo/hugo.toml](mdc:hugo/hugo.toml) - Main Hugo configuration +- [hugo/layouts/](mdc:hugo/layouts/) - Custom layouts and templates +- [hugo/archetypes/](mdc:hugo/archetypes/) - Content templates +- [.gitmodules](mdc:.gitmodules) - Theme submodule configuration +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc new file mode 100644 index 0000000..a7560b2 --- /dev/null +++ b/.cursor/rules/project-structure.mdc @@ -0,0 +1,35 @@ +# Project Structure Guide + +This is a personal website project using Hugo with Python build tools. The project has a hybrid structure: + +## Core Structure +- [hugo/](mdc:hugo/) - Contains all Hugo-related content (the actual website) +- [src/guhcampos/](mdc:src/guhcampos/) - Python package with build tools and CLI +- [tests/](mdc:tests/) - Python tests for the build tools +- [build/](mdc:build/) - Generated build artifacts (gitignored) + +## Key Configuration Files +- [pyproject.toml](mdc:pyproject.toml) - Python project configuration and dependencies +- [hugo/hugo.toml](mdc:hugo/hugo.toml) - Hugo site configuration +- [.envrc](mdc:.envrc) - Environment variables for development +- [.gitmodules](mdc:.gitmodules) - Git submodules (Hugo themes) + +## Python Package Structure +- [src/guhcampos/cli.py](mdc:src/guhcampos/cli.py) - Click-based CLI entry point +- [src/guhcampos/hugo.py](mdc:src/guhcampos/hugo.py) - Hugo-specific operations +- [src/guhcampos/spotify/](mdc:src/guhcampos/spotify/) - Spotify API integration +- [src/guhcampos/settings.py](mdc:src/guhcampos/settings.py) - Configuration management + +## Development Tools +- Uses `uv` for Python dependency management +- Ruff for linting and formatting +- Pytest for testing +- VS Code configuration in [.vscode/](mdc:.vscode/) + +## Build Process +The Python package provides CLI commands to build and serve the Hugo site, with +additional integrations for Spotify playlists and other content sources. +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/python-development.mdc b/.cursor/rules/python-development.mdc new file mode 100644 index 0000000..b32241e --- /dev/null +++ b/.cursor/rules/python-development.mdc @@ -0,0 +1,43 @@ +# Python Development Standards + +## Code Style +- Use Black for formatting (88 character line length) +- Use Ruff for linting and import sorting +- Follow type hints with Pydantic models +- Use `uv` for dependency management + +## Project Structure +- All Python code goes in [src/guhcampos/](mdc:src/guhcampos/) +- Tests go in [tests/](mdc:tests/) +- Use Pydantic models for data validation +- Keep CLI logic separate from business logic + +## Testing +- Use pytest with `tmp_path` fixture for file operations +- Mock external APIs (Spotify, etc.) +- Test both success and failure scenarios +- Use descriptive test names and docstrings + +## Dependencies +- Core dependencies in [pyproject.toml](mdc:pyproject.toml) +- Development dependencies include pytest, ruff, black +- Use Pydantic for data models and validation +- Use Click for CLI interface +- Use Rich for console output + +## Key Patterns +- Use generators for pagination (Spotify API) +- Use Pydantic models for API responses +- Use pathlib.Path for file operations +- Use environment variables for configuration +- Use logging/console output for user feedback + +## File Organization +- [src/guhcampos/cli.py](mdc:src/guhcampos/cli.py) - CLI entry points only +- [src/guhcampos/core.py](mdc:src/guhcampos/core.py) - Core utilities +- [src/guhcampos/spotify/](mdc:src/guhcampos/spotify/) - Spotify integration +- [src/guhcampos/settings.py](mdc:src/guhcampos/settings.py) - Configuration +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/spotify-integration.mdc b/.cursor/rules/spotify-integration.mdc new file mode 100644 index 0000000..ee7fe5c --- /dev/null +++ b/.cursor/rules/spotify-integration.mdc @@ -0,0 +1,46 @@ +# Spotify Integration Guide + +## Architecture +- Spotify integration in [src/guhcampos/spotify/](mdc:src/guhcampos/spotify/) +- [src/guhcampos/spotify/client.py](mdc:src/guhcampos/spotify/client.py) - API client and business logic +- [src/guhcampos/spotify/models.py](mdc:src/guhcampos/spotify/models.py) - Pydantic models for data validation + +## Key Patterns +- Use Pydantic models for API responses (SpotifyPlaylist, SpotifyTrack) +- Implement pagination using generators in `fetch_playlists()` +- Use environment variables for API credentials +- Save playlists as M3U files for Hugo integration + +## API Usage +- Client credentials flow for authentication +- Pagination with offset/limit for playlists +- Track sorting by artist and name +- Error handling with console output + +## Data Models +- `SpotifyPlaylist`: Playlist metadata with tracks +- `SpotifyTrack`: Track information with duration +- `to_m3u()` method for playlist export +- `to_json()` method for data serialization + + +## Testing +- Mock API responses in [tests/test_spotify.py](mdc:tests/test_spotify.py) +- Use `tmp_path` fixture for file operations +- Test pagination with realistic mock data +- Test both success and error scenarios + +## Configuration +- Spotify credentials in environment variables +- Output directory configurable via CLI +- Playlist filtering by name +- Progress reporting with Rich console + +## File Formats +- M3U format for playlist export +- JSON format for data storage +- Proper URL validation with Pydantic HttpUrl +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/testing-standards.mdc b/.cursor/rules/testing-standards.mdc new file mode 100644 index 0000000..25a0761 --- /dev/null +++ b/.cursor/rules/testing-standards.mdc @@ -0,0 +1,53 @@ +# Testing Standards + +## Test Structure +- All tests in [tests/](mdc:tests/) directory +- Use pytest as testing framework +- Follow naming convention: `test_*.py` files +- Use descriptive test function names + +## Mocking Patterns +- Mock external APIs (Spotify, HTTP requests) +- Use `@patch` decorator for dependency injection +- Mock file system operations with `tmp_path` fixture +- Create realistic mock data for pagination testing + +## File Operations +- Always use `tmp_path` fixture for file output +- Never write to repository root in tests +- Clean up temporary files automatically +- Test both file creation and content validation + +## API Testing +- Mock HTTP responses with realistic data +- Test pagination logic with multiple mock responses +- Test error scenarios and exception handling +- Validate response parsing and model creation + +## Test Data +- Use Pydantic models for test data validation +- Create realistic mock responses matching API format +- Test edge cases (empty responses, pagination boundaries) +- Use valid URLs for HttpUrl validation + +## Assertions +- Test both success and failure paths +- Validate data types and model instances +- Check function call counts and parameters +- Verify file content and format + +## Test Organization +- Group related tests in same file +- Use descriptive docstrings for test functions +- Test one concept per test function +- Use setup/teardown when needed + +## Key Examples +- [tests/test_spotify.py](mdc:tests/test_spotify.py) - Spotify API testing +- Pagination testing with realistic mock data +- File output testing with `tmp_path` +- Model validation testing +description: +globs: +alwaysApply: false +--- diff --git a/README.md b/README.md index 8734bee..0e517cc 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ # guhcampos.github.io This is the sorce repository of my personal webpage, hosted on -https://guhcampos.github.io. As you see I decided to open source the whole +https://guhcampos.github.io. + +As you see I decided to open source the whole thing just to make the whole process public as I plan to add interesting stuff here, such as fetching content remotely from multiple platforms I use on my personal life. In the end, it doubles as an experimental platform for me to play with a bunch of adjacent technologies, from some frontend stuff I'm not too familiar with to the new AI tooling around. + +It should be secure as a public repository. If it isn't, then I'm the one to +blame anyway so there's that. From d6a8e0c9c94699a10b5916f4a6556a53bb4d2c07 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Mon, 14 Jul 2025 23:45:10 -0300 Subject: [PATCH 09/16] checkpoint --- .gitignore | 39 +- hugo/config/_default/hugo.toml | 73 +--- hugo/config/_default/languages.en.toml | 35 +- hugo/config/_default/languages.pt-br.toml | 18 + hugo/config/_default/menus.en.toml | 82 ++-- hugo/config/_default/menus.pt-br.toml | 37 ++ hugo/config/_default/module.toml | 20 + hugo/config/_default/params.toml | 11 + build/.gitkeep => hugo/content/_index.md | 0 hugo/content/en/about.md | 6 + hugo/content/en/playlists/_content.gotmpl | 38 ++ hugo/content/en/playlists/_index.md | 12 + hugo/content/en/posts/_index.md | 0 hugo/content/pt/about.md | 6 + hugo/content/pt/playlists/_index.md | 5 + hugo/content/pt/posts/_index.md | 0 hugo/data/playlists/.gitkeep | 0 hugo/hugo.toml | 105 ------ .../under-construction.html | 0 .../layouts/_shortcodes/playlist-display.html | 352 ++++++++++++++++++ hugo/layouts/_shortcodes/playlists-index.html | 172 +++++++++ .../render-partial.html | 0 hugo/layouts/resources/list.html | 40 -- hugo/layouts/resources/section.html | 89 ----- hugo/layouts/tags/list.html | 82 ---- hugo/layouts/tags/term.html | 41 -- hugo/static/.gitkeep | 0 src/guhcampos/settings.py | 4 +- tests/test_spotify.py | 275 -------------- 29 files changed, 725 insertions(+), 817 deletions(-) create mode 100644 hugo/config/_default/languages.pt-br.toml create mode 100644 hugo/config/_default/menus.pt-br.toml rename build/.gitkeep => hugo/content/_index.md (100%) create mode 100644 hugo/content/en/about.md create mode 100644 hugo/content/en/playlists/_content.gotmpl create mode 100644 hugo/content/en/playlists/_index.md create mode 100644 hugo/content/en/posts/_index.md create mode 100644 hugo/content/pt/about.md create mode 100644 hugo/content/pt/playlists/_index.md create mode 100644 hugo/content/pt/posts/_index.md create mode 100644 hugo/data/playlists/.gitkeep delete mode 100644 hugo/hugo.toml rename hugo/layouts/{partials => _partials}/under-construction.html (100%) create mode 100644 hugo/layouts/_shortcodes/playlist-display.html create mode 100644 hugo/layouts/_shortcodes/playlists-index.html rename hugo/layouts/{shortcodes => _shortcodes}/render-partial.html (100%) delete mode 100644 hugo/layouts/resources/list.html delete mode 100644 hugo/layouts/resources/section.html delete mode 100644 hugo/layouts/tags/list.html delete mode 100644 hugo/layouts/tags/term.html create mode 100644 hugo/static/.gitkeep delete mode 100644 tests/test_spotify.py diff --git a/.gitignore b/.gitignore index e560b69..62906cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - __marimo__/ __pycache__/ __pypackages__/ @@ -120,6 +83,7 @@ hugo_stats.json hugo.darwin hugo.exe hugo.linux +hugo/data/playlists/ instance/ ipython_config.py lib/ @@ -136,6 +100,7 @@ profile_default/ public/ sdist/ share/python-wheels/ +static/ target/ Thumbs.db var/ diff --git a/hugo/config/_default/hugo.toml b/hugo/config/_default/hugo.toml index d89e764..67e42f3 100644 --- a/hugo/config/_default/hugo.toml +++ b/hugo/config/_default/hugo.toml @@ -1,72 +1,29 @@ +baseURL = 'https://guhcampos.github.io/' +languageCode = 'en' +defaultContentLanguage = 'en' +title = 'Gustavo Campos' +# sectionPagesMenu = "main" + +themesDir = "themes" theme = "blowfish" -baseURL = "https://guhcampos.github.io/" -defaultContentLanguage = "en" -pluralizeListTitles = "true" + enableRobotsTXT = true summaryLength = 0 -buildDrafts = false -buildFuture = false - -enableEmoji = true - -# googleAnalytics = "G-XXXXXXXXX" +[module] + [[module.imports]] + path = "blowfish" + disable = false [pagination] - pagerSize = 100 - -[imaging] - anchor = 'Center' + pagerSize = 10 [taxonomies] - tag = "tags" category = "categories" - author = "authors" series = "series" + tag = "tags" + topic = "topics" -[sitemap] - changefreq = 'daily' - filename = 'sitemap.xml' - priority = 0.5 [outputs] home = ["HTML", "RSS", "JSON"] - -[related] - threshold = 0 - toLower = false - - [[related.indices]] - name = "tags" - weight = 100 - - [[related.indices]] - name = "categories" - weight = 100 - - [[related.indices]] - name = "series" - weight = 50 - - [[related.indices]] - name = "authors" - weight = 20 - - [[related.indices]] - name = "date" - weight = 10 - - [[related.indices]] - applyFilter = false - name = 'fragmentrefs' - type = 'fragments' - weight = 10 - -[markup] - [markup.goldmark] - [markup.goldmark.extensions] - [markup.goldmark.extensions.passthrough] - enable = true - [markup.goldmark.extensions.passthrough.delimiters] - block = [['\[', '\]'], ['$$', '$$']] - inline = [['\(', '\)']] diff --git a/hugo/config/_default/languages.en.toml b/hugo/config/_default/languages.en.toml index cb292c8..89dbf76 100644 --- a/hugo/config/_default/languages.en.toml +++ b/hugo/config/_default/languages.en.toml @@ -1,14 +1,14 @@ -disabled = false +contentDir = "content/en" languageCode = "en" languageName = "English" +title = "guhcampos en" weight = 1 -title = "guhcampos" [params] -displayName = "EN" +displayName = "en" isoCode = "en" rtl = false -dateFormat = "2 January 2006" +dateFormat = "January 2, 2006" description = "Guhcampos Digital Garden" [params.author] @@ -17,30 +17,3 @@ description = "Guhcampos Digital Garden" headline = "Brazilian, Millenial, Tinkerer, SCUBA diver, Nerd." bio = "Brazilian, Millenial, Tinkerer, Nerd. I write about random stuff and for some reason I decided to start publishing it." - [[params.author.links]] - github = "https://github.com/guhcampos" - - [[params.author.links]] - gitlab = "https://gitlab.com/guhcampos" - - [[params.author.links]] - instagram = "https://www.instagram.com/guhcampos.stuff/" - - [[params.author.links]] - lastfm = "https://www.last.fm/user/guhcampos" - - [[params.author.links]] - linkedin = "https://www.linkedin.com/in/gucampos/" - - [[params.author.links]] - reddit = "https://www.reddit.com/user/guhcampos/" - - [[params.author.links]] - spotify = "https://open.spotify.com/user/12132336829" - - [[params.author.links]] - steam = "https://steamcommunity.com/id/guhcampos/" - -["params.author"] -headline = "Brazilian, Millenial, Tinkerer, SCUBA diver, Nerd." -bio = "I'm Gustavo Campos, a Software, Platform and Site Reliability Engineer based in Belo Horizonte, Brazil. This website is populated with random stuff I write and somehow still think I should publish even after proof-reading it." diff --git a/hugo/config/_default/languages.pt-br.toml b/hugo/config/_default/languages.pt-br.toml new file mode 100644 index 0000000..31a54a9 --- /dev/null +++ b/hugo/config/_default/languages.pt-br.toml @@ -0,0 +1,18 @@ +contentDir = "content/pt" +languageCode = "pt-br" +languageName = "Português" +title = "guhcampos br" +weight = 2 + +[params] +displayName = "pt-br" +isoCode = "pt-BR" +rtl = false +dateFormat = "2 January 2006" +description = "Guhcampos Digital Garden" + + [params.author] + name = "Gustavo Campos" + email = "guhcampos@gmail.com" + headline = "Brasileiro, Millenial, Gambiarreiro, Mergulhador, Nerd." + bio = "Escrevo sobre coisas aleatórias e, por algum motivo, decidi começar a publicar." diff --git a/hugo/config/_default/menus.en.toml b/hugo/config/_default/menus.en.toml index 062bc0e..e01820b 100644 --- a/hugo/config/_default/menus.en.toml +++ b/hugo/config/_default/menus.en.toml @@ -1,69 +1,37 @@ -# -- Main Menu -- -# The main menu is displayed in the header at the top of the page. -# Acceptable parameters are name, pageRef, page, url, title, weight. -# -# The simplest menu configuration is to provide: -# name = The name to be displayed for this menu link -# pageRef = The identifier of the page or section to link to -# -# By default the menu is ordered alphabetically. This can be -# overridden by providing a weight value. The menu will then be -# ordered by weight from lowest to highest. +[[main]] +name = "Blog" +pageRef = "posts" +weight = 10 -#[[main]] -# name = "Blog" -# pageRef = "posts" -# weight = 10 - -#[[main]] -# name = "Parent" -# weight = 20 - -#[[main]] -# name = "example sub-menu 1" -# parent = "Parent" -# pageRef = "posts" -# weight = 20 - -#[[main]] -# name = "example sub-menu 2" -# parent = "Parent" -# pageRef = "posts" +# [[main]] +# name = "Music" # weight = 20 -#[[subnavigation]] -# name = "An interesting topic" -# pageRef = "tags/interesting-topic" -# weight = 10 - -#[[subnavigation]] -# name = "My Awesome Category" -# pre = "github" -# pageRef = "categories/awesome" -# weight = 20 +# [[main]] +# name = "Playlists" +# parent = "Music" +# pageRef = "playlists" +# weight = 30 # [[main]] # name = "Resources" # pageRef = "resources" -# weight = 20 - -#[[main]] -# name = "Tags" -# pageRef = "tags" -# weight = 30 +# weight = 40 +[[main]] +name = "About" +pageRef = "about" +weight = 50 -# -- Footer Menu -- -# The footer menu is displayed at the bottom of the page, just before -# the copyright notice. Configure as per the main menu above. +# ---------------- +[[footer]] +name = "Tags" +pageRef = "tags" +weight = 10 -# [[footer]] -# name = "Tags" -# pageRef = "tags" -# weight = 10 +[[footer]] +name = "Categories" +pageRef = "categories" +weight = 20 -# [[footer]] -# name = "Categories" -# pageRef = "categories" -# weight = 20 diff --git a/hugo/config/_default/menus.pt-br.toml b/hugo/config/_default/menus.pt-br.toml new file mode 100644 index 0000000..85bb3d0 --- /dev/null +++ b/hugo/config/_default/menus.pt-br.toml @@ -0,0 +1,37 @@ + +[[main]] +name = "Blog" +pageRef = "posts" +weight = 10 + +# [[main]] +# name = "Música" +# weight = 20 + +# [[main]] +# name = "Playlists" +# parent = "Música" +# pageRef = "playlists" +# weight = 20 + +# [[main]] +# name = "Recursos" +# pageRef = "recursos" +# weight = 20 + +[[main]] +name = "Sobre" +pageRef = "about" +weight = 50 + +# ---------------- + +[[footer]] +name = "Tags" +pageRef = "tags" +weight = 10 + +[[footer]] +name = "Categorias" +pageRef = "categorias" +weight = 20 diff --git a/hugo/config/_default/module.toml b/hugo/config/_default/module.toml index 74f7727..1feb525 100644 --- a/hugo/config/_default/module.toml +++ b/hugo/config/_default/module.toml @@ -1,3 +1,23 @@ [hugoVersion] extended = false min = "0.87.0" + +[[module.mounts]] +source = 'static/playlists' +target = 'data/playlists' +includeFiles = ['*.json'] + +[[module.mounts]] +source = 'content/en' +target = 'content' +lang = 'en' + +[[module.mounts]] +source = 'content/pt' +target = 'content' +lang = 'pt' + +[[module.mounts]] +source = 'content/en' +target = 'content' +lang = 'pt' diff --git a/hugo/config/_default/params.toml b/hugo/config/_default/params.toml index 815f1e4..045653f 100644 --- a/hugo/config/_default/params.toml +++ b/hugo/config/_default/params.toml @@ -18,6 +18,17 @@ verification = { } rssnext = { } highlightCurrentMenuArea = "" smartTOC = true +[author] + +[[author.links]] +github = "https://github.com/guhcampos" +gitlab = "https://gitlab.com/guhcampos" +instagram = "https://www.instagram.com/guhcampos.stuff/" +lastfm = "https://www.last.fm/user/guhcampos" +linkedin = "https://www.linkedin.com/in/gucampos/" +reddit = "https://www.reddit.com/user/guhcampos/" +spotify = "https://open.spotify.com/user/12132336829" +steam = "https://steamcommunity.com/id/guhcampos/" [header] layout = "basic" diff --git a/build/.gitkeep b/hugo/content/_index.md similarity index 100% rename from build/.gitkeep rename to hugo/content/_index.md diff --git a/hugo/content/en/about.md b/hugo/content/en/about.md new file mode 100644 index 0000000..22d55f8 --- /dev/null +++ b/hugo/content/en/about.md @@ -0,0 +1,6 @@ +--- +title: About Me +showDate: false +--- + +## About Me diff --git a/hugo/content/en/playlists/_content.gotmpl b/hugo/content/en/playlists/_content.gotmpl new file mode 100644 index 0000000..d82853a --- /dev/null +++ b/hugo/content/en/playlists/_content.gotmpl @@ -0,0 +1,38 @@ +{{ .EnableAllLanguages }} +{{ $data := .Site.Data.books }} +{{ range $data }} + + {{ $content := dict "mediaType" "text/markdown" "value" .summary }} + {{ $dates := dict "date" (time.AsTime .date) }} + {{ $params := dict "author" .author "isbn" .isbn "rating" .rating "tags" .tags }} + {{ $page := dict + "content" $content + "dates" $dates + "kind" "page" + "params" $params + "path" .title + "title" .title + }} + {{ $.AddPage $page }} + + {{ $item := . }} + {{ with $url := $item.cover }} + {{ with try (resources.GetRemote $url) }} + {{ with .Err }} + {{ errorf "Unable to get remote resource %s: %s" $url . }} + {{ else with .Value }} + {{ $content := dict "mediaType" .MediaType.Type "value" .Content }} + {{ $params := dict "alt" $item.title }} + {{ $resource := dict + "content" $content + "params" $params + "path" (printf "%s/cover.%s" $item.title .MediaType.SubType) + }} + {{ $.AddResource $resource }} + {{ else }} + {{ errorf "Unable to get remote resource %s" $url }} + {{ end }} + {{ end }} + {{ end }} + +{{ end }} diff --git a/hugo/content/en/playlists/_index.md b/hugo/content/en/playlists/_index.md new file mode 100644 index 0000000..c792a6a --- /dev/null +++ b/hugo/content/en/playlists/_index.md @@ -0,0 +1,12 @@ +--- +title: Playlists +--- + +I'm a shuffle type of music listener, and as such I make heavy use of playlists +to mix and match music I like and listen it for long periods of time. This is a +list of playlists I have created over time that may be interesting to someone +else out there. + +Having them here also helps me not be a prisioner of any one music listening +service: as I have them all in JSON and M3U formats, I can easily migrate them +between different services if I so wish to. diff --git a/hugo/content/en/posts/_index.md b/hugo/content/en/posts/_index.md new file mode 100644 index 0000000..e69de29 diff --git a/hugo/content/pt/about.md b/hugo/content/pt/about.md new file mode 100644 index 0000000..d5ae68a --- /dev/null +++ b/hugo/content/pt/about.md @@ -0,0 +1,6 @@ +--- +title: Sobre Mim +showDate: false +--- + +## Sobre Mim diff --git a/hugo/content/pt/playlists/_index.md b/hugo/content/pt/playlists/_index.md new file mode 100644 index 0000000..48a3dcb --- /dev/null +++ b/hugo/content/pt/playlists/_index.md @@ -0,0 +1,5 @@ +--- +title: Playlists +--- + +Minhas Playlists diff --git a/hugo/content/pt/posts/_index.md b/hugo/content/pt/posts/_index.md new file mode 100644 index 0000000..e69de29 diff --git a/hugo/data/playlists/.gitkeep b/hugo/data/playlists/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/hugo/hugo.toml b/hugo/hugo.toml deleted file mode 100644 index 4e1092f..0000000 --- a/hugo/hugo.toml +++ /dev/null @@ -1,105 +0,0 @@ -baseURL = 'https://guhcampos.github.io/' -languageCode = 'en-us' -defaultContentLanguage = 'en' -title = 'Gustavo Campos' - -# Use theme from local directory -themesDir = "themes" -theme = "blowfish" - -enableRobotsTXT = true -summaryLength = 0 - -[module] - [[module.imports]] - path = "blowfish" - disable = false - -[pagination] - pagerSize = 10 - -[taxonomies] - tag = "tags" - category = "categories" - author = "authors" - series = "series" - -[outputs] - home = ["HTML", "RSS", "JSON"] - -[menu] - [[menu.main]] - name = "Blog" - pageRef = "blog" - weight = 10 - [[menu.main]] - name = "Topics" - pageRef = "topics" - weight = 20 - [[menu.main]] - name = "Lists" - pageRef = "lists" - weight = 30 - [[menu.main]] - name = "Resources" - pageRef = "resources" - weight = 40 - [[menu.main]] - name = "Tags" - pageRef = "tags" - weight = 50 - -[params] - description = "Personal website and blog" - mainSections = ["blog"] - - [params.header] - layout = "basic" # options: basic, fixed - showTitle = true - showMenu = true - - [params.footer] - showCopyright = true - showThemeAttribution = true - showAppearanceSwitcher = true - showScrollToTop = true - - [params.homepage] - layout = "page" # options: page, profile, hero, card, background, custom - showRecent = true - showRecentItems = 5 - showMoreLink = true - showMoreLinkDest = "/blog" - - [params.article] - showDate = true - showDateUpdated = true - showAuthor = true - showBreadcrumbs = true - showDraftLabel = true - showEdit = false - showHeadingAnchors = true - showPagination = true - showReadingTime = true - showTableOfContents = true - showTaxonomies = true - showWordCount = true - showComments = false - - [params.list] - showBreadcrumbs = true - showSummary = true - showTableOfContents = true - showTaxonomies = true - groupByYear = true - - [params.sitemap] - excludedKinds = ["taxonomy", "term"] - - [params.search] - enable = true - type = "fuse" - - [params.colorScheme] - default = "dark" - toggle = true diff --git a/hugo/layouts/partials/under-construction.html b/hugo/layouts/_partials/under-construction.html similarity index 100% rename from hugo/layouts/partials/under-construction.html rename to hugo/layouts/_partials/under-construction.html diff --git a/hugo/layouts/_shortcodes/playlist-display.html b/hugo/layouts/_shortcodes/playlist-display.html new file mode 100644 index 0000000..759d49d --- /dev/null +++ b/hugo/layouts/_shortcodes/playlist-display.html @@ -0,0 +1,352 @@ +{{ $playlist_id := .Get "playlist_id" }} +{{ $playlist_data := index .Site.Data.playlists $playlist_id }} + +{{ if $playlist_data }} +{{ $total_duration := 0 }} +{{ range $playlist_data.tracks }} + {{ $total_duration = add $total_duration (div .duration_ms 1000) }} +{{ end }} +
+
+

{{ $playlist_data.name }}

+

{{ $playlist_data.description }}

+
+ Created by: {{ $playlist_data.owner }} + {{ len $playlist_data.tracks }} tracks + {{ printf "%.0f" (div $total_duration 60.0) }} minutes +
+ +
+ +
+ +
+ + +
+
+ +
+ + + + + + + + + + + + {{ range $playlist_data.tracks }} + + + + + + + + {{ end }} + +
ArtistAlbumTrackDurationActions
{{ index .artists 0 }}{{ .album }}{{ .name }}{{ printf "%d:%02d" (div (div .duration_ms 1000) 60) (mod (div .duration_ms 1000) 60) }} + + + +
+
+
+ + + + +{{ else }} +
+

Playlist data not found for ID: {{ $playlist_id }}

+
+{{ end }} diff --git a/hugo/layouts/_shortcodes/playlists-index.html b/hugo/layouts/_shortcodes/playlists-index.html new file mode 100644 index 0000000..0ef265b --- /dev/null +++ b/hugo/layouts/_shortcodes/playlists-index.html @@ -0,0 +1,172 @@ +{{ $playlists := where .Site.RegularPages "Section" "playlists" }} +{{ $playlists_data := .Site.Data.playlists }} + +
+
+ {{ range $playlists }} + {{ $playlist_id := .Params.playlist_id }} + {{ $playlist_data := index $playlists_data $playlist_id }} + {{ if $playlist_data }} + {{ $total_duration := 0 }} + {{ range $playlist_data.tracks }} + {{ $total_duration = add $total_duration (div .duration_ms 1000) }} + {{ end }} +
+
+

{{ $playlist_data.name }}

+

{{ $playlist_data.description }}

+
+
+ + + {{ $playlist_data.owner }} + + + + {{ len $playlist_data.tracks }} tracks + + + + {{ printf "%.0f" (div $total_duration 60.0) }} min + +
+ +
+ {{ end }} + {{ end }} +
+
+ + diff --git a/hugo/layouts/shortcodes/render-partial.html b/hugo/layouts/_shortcodes/render-partial.html similarity index 100% rename from hugo/layouts/shortcodes/render-partial.html rename to hugo/layouts/_shortcodes/render-partial.html diff --git a/hugo/layouts/resources/list.html b/hugo/layouts/resources/list.html deleted file mode 100644 index 6c59de5..0000000 --- a/hugo/layouts/resources/list.html +++ /dev/null @@ -1,40 +0,0 @@ -{{ define "main" }} -
-
- {{ if .Title }} -

- {{ .Title }} -

- {{ end }} -
- {{ .Description | markdownify }} -
-
- - {{/* Display pages in a grid */}} -
- {{ range .Pages }} -
-

- {{ .Title }} -

- {{ with .Description }} -

- {{ . }} -

- {{ end }} - {{ with .Params.tags }} -
- {{ range . }} - - #{{ . }} - - {{ end }} -
- {{ end }} -
- {{ end }} -
-
-{{ end }} diff --git a/hugo/layouts/resources/section.html b/hugo/layouts/resources/section.html deleted file mode 100644 index 24fa327..0000000 --- a/hugo/layouts/resources/section.html +++ /dev/null @@ -1,89 +0,0 @@ -{{ define "main" }} -
-
- {{ if .Title }} -

- {{ .Title }} -

- {{ end }} -
- {{ .Description | markdownify }} -
-
- - {{/* Get all pages and group them by category */}} - {{ $pages := where .Pages "Kind" "page" }} - {{ $categories := slice }} - {{ range $pages }} - {{ with .Params.categories }} - {{ range . }} - {{ $categories = $categories | append . }} - {{ end }} - {{ end }} - {{ end }} - {{ $uniqueCategories := uniq $categories }} - - {{/* Display resources grouped by category */}} - {{ range $category := $uniqueCategories }} -
-

{{ $category }}

-
- {{ range $pages }} - {{ if in .Params.categories $category }} -
-

- {{ .Title }} -

- {{ with .Description }} -

- {{ . }} -

- {{ end }} - {{ with .Params.tags }} -
- {{ range . }} - - {{ . }} - - {{ end }} -
- {{ end }} -
- {{ end }} - {{ end }} -
-
- {{ end }} - - {{/* Display uncategorized resources if any */}} - {{ $uncategorized := where $pages "Params.categories" nil }} - {{ with $uncategorized }} -
-

Other Resources

-
- {{ range . }} -
-

- {{ .Title }} -

- {{ with .Description }} -

- {{ . }} -

- {{ end }} - {{ with .Params.tags }} -
- {{ range . }} - - {{ . }} - - {{ end }} -
- {{ end }} -
- {{ end }} -
-
- {{ end }} -
-{{ end }} diff --git a/hugo/layouts/tags/list.html b/hugo/layouts/tags/list.html deleted file mode 100644 index 1fee7f6..0000000 --- a/hugo/layouts/tags/list.html +++ /dev/null @@ -1,82 +0,0 @@ -{{ define "main" }} -
-
-

- Tags -

-
- Browse content by tags -
-
- - {{/* Get all tags and sort them */}} - {{ $tags := slice }} - {{ range .Site.Pages }} - {{ with .Params.tags }} - {{ range . }} - {{ $tags = $tags | append . }} - {{ end }} - {{ end }} - {{ end }} - {{ $uniqueTags := uniq $tags | sort }} - - {{/* Display tag cloud */}} -
- {{ range $uniqueTags }} - {{ $tag := . }} - {{ $count := 0 }} - {{ range $.Site.Pages }} - {{ if in .Params.tags $tag }} - {{ $count = add $count 1 }} - {{ end }} - {{ end }} - - {{ . }} ({{ $count }}) - - {{ end }} -
- - {{/* Display content grouped by tags */}} - {{ range $uniqueTags }} -
-

- {{ . }} -

-
- {{ range $.Site.Pages }} - {{ if in .Params.tags $ }} -
-

- {{ .Title }} -

- {{ with .Description }} -

- {{ . }} -

- {{ end }} -
- {{ with .Section }} - - {{ . }} - - {{ end }} - {{ with .Params.categories }} - {{ range . }} - - {{ . }} - - {{ end }} - {{ end }} -
-
- {{ end }} - {{ end }} -
-
- {{ end }} -
-{{ end }} diff --git a/hugo/layouts/tags/term.html b/hugo/layouts/tags/term.html deleted file mode 100644 index a83a80b..0000000 --- a/hugo/layouts/tags/term.html +++ /dev/null @@ -1,41 +0,0 @@ -{{ define "main" }} -
-
-

- Tag: {{ .Title }} -

-
- Content tagged with "{{ .Title }}" -
-
- -
- {{ range .Pages }} -
-

- {{ .Title }} -

- {{ with .Description }} -

- {{ . }} -

- {{ end }} -
- {{ with .Section }} - - {{ . }} - - {{ end }} - {{ with .Params.categories }} - {{ range . }} - - {{ . }} - - {{ end }} - {{ end }} -
-
- {{ end }} -
-
-{{ end }} diff --git a/hugo/static/.gitkeep b/hugo/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/guhcampos/settings.py b/src/guhcampos/settings.py index 8a31595..6fc4961 100644 --- a/src/guhcampos/settings.py +++ b/src/guhcampos/settings.py @@ -18,7 +18,7 @@ class Settings(BaseSettings): "This is Guhcampos", "01.01 Beard Growing", ] - spotify_playlists_output_dir: Path = REPO_ROOT / "build" / "spotify-playlists" + spotify_playlists_output_dir: Path = REPO_ROOT / "hugo" / "static" / "playlists" -settings = Settings() +settings = Settings() # type: ignore diff --git a/tests/test_spotify.py b/tests/test_spotify.py deleted file mode 100644 index e90eeae..0000000 --- a/tests/test_spotify.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Tests for the Spotify module.""" - -from unittest.mock import Mock, patch - -import pytest - -from guhcampos.spotify.client import ( - SpotifyAPI, - fetch_spotify_playlists, - list_spotify_playlists, -) -from guhcampos.spotify.models import SpotifyPlaylist, SpotifyTrack - - -def test_spotify_api_initialization(): - """Test SpotifyAPI initialization.""" - api = SpotifyAPI("test_client_id", "test_client_secret") - assert api.client_id == "test_client_id" - assert api.client_secret == "test_client_secret" - assert api.access_token is None - assert api.base_url == "https://api.spotify.com/v1" - - -@patch("guhcampos.spotify.client.requests.post") -def test_spotify_api_authenticate_success(mock_post): - """Test successful authentication.""" - mock_response = Mock() - mock_response.json.return_value = {"access_token": "test_token"} - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response - - api = SpotifyAPI("test_client_id", "test_client_secret") - result = api.authenticate() - - assert result is True - assert api.access_token == "test_token" - - -@patch("guhcampos.spotify.client.requests.post") -def test_spotify_api_authenticate_failure(mock_post): - """Test failed authentication.""" - mock_post.side_effect = Exception("Auth failed") - - api = SpotifyAPI("test_client_id", "test_client_secret") - - with pytest.raises(Exception): - api.authenticate() - - -@patch("guhcampos.spotify.client.requests.get") -def test_fetch_playlists_pagination(mock_get): - """Test fetch_playlists with pagination.""" - - def mock_get_side_effect(*args, **kwargs): - mock_response = Mock() - mock_response.raise_for_status.return_value = None - - # Check the offset parameter to determine which page to return - params = kwargs.get("params", {}) - offset = params.get("offset", 0) - - print(f"DEBUG: Mock called with offset={offset}, params={params}") - - if offset == 0: - # First page: 50 items (full page, should continue) - print("DEBUG: Returning first page with 50 items") - mock_response.json.return_value = { - "items": [ - { - "id": str(i), - "name": f"Playlist {i}", - "description": f"Test playlist {i}", - "owner": {"display_name": "Test User"}, - "external_urls": { - "spotify": f"https://open.spotify.com/playlist/{i}" - }, - } - for i in range(1, 51) # 50 items - ] - } - elif offset == 50: - # Second page: 3 items (partial page, should stop) - print("DEBUG: Returning second page with 3 items") - mock_response.json.return_value = { - "items": [ - { - "id": str(i), - "name": f"Playlist {i}", - "description": f"Test playlist {i}", - "owner": {"display_name": "Test User"}, - "external_urls": { - "spotify": f"https://open.spotify.com/playlist/{i}" - }, - } - for i in range(51, 54) # 3 items - ] - } - else: - # Any other offset: empty - print(f"DEBUG: Returning empty page for offset {offset}") - mock_response.json.return_value = {"items": []} - - return mock_response - - mock_get.side_effect = mock_get_side_effect - - api = SpotifyAPI("test_client_id", "test_client_secret") - api.access_token = "test_token" - - playlists = list(api.fetch_playlists("test_user")) - - assert len(playlists) == 53 # 50 from first page + 3 from second page - assert playlists[0].name == "Playlist 1" - assert playlists[49].name == "Playlist 50" # Last item from first page - assert playlists[50].name == "Playlist 51" # First item from second page - assert playlists[52].name == "Playlist 53" # Last item from second page - assert all(isinstance(p, SpotifyPlaylist) for p in playlists) - - -@patch("guhcampos.spotify.client.requests.get") -def test_get_playlist_tracks(mock_get): - """Test get_playlist_tracks method.""" - mock_response = Mock() - mock_response.json.return_value = { - "items": [ - { - "track": { - "name": "Test Song", - "artists": [{"name": "Test Artist"}], - "album": {"name": "Test Album"}, - "duration_ms": 180000, - "external_urls": {"spotify": "https://open.spotify.com/track/test"}, - } - } - ] - } - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - api = SpotifyAPI("test_client_id", "test_client_secret") - api.access_token = "test_token" - - tracks = api.get_playlist_tracks("test_playlist_id") - - assert len(tracks) == 1 - assert isinstance(tracks[0], SpotifyTrack) - assert tracks[0].name == "Test Song" - assert tracks[0].artists == ["Test Artist"] - assert tracks[0].album == "Test Album" - assert tracks[0].duration_ms == 180000 - - -def test_spotify_track_model(): - """Test SpotifyTrack model.""" - track = SpotifyTrack( - name="Test Song", - artists=["Test Artist"], - album="Test Album", - duration_ms=180000, - url="https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh", - ) - - assert track.name == "Test Song" - assert track.artists == ["Test Artist"] - assert track.album == "Test Album" - assert track.duration_ms == 180000 - assert track.duration == 180 # duration in seconds - - -def test_spotify_playlist_model(): - """Test SpotifyPlaylist model.""" - playlist = SpotifyPlaylist( - id="test_id", - name="Test Playlist", - tracks=[], - url="https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", - description="Test description", - owner="Test User", - ) - - assert playlist.id == "test_id" - assert playlist.name == "Test Playlist" - assert playlist.description == "Test description" - assert playlist.owner == "Test User" - assert playlist.slug == "test-playlist" # slugified name - - -def test_playlist_to_m3u(): - """Test playlist to_m3u method.""" - track = SpotifyTrack( - name="Test Song", - artists=["Test Artist"], - album="Test Album", - duration_ms=180000, - url="https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh", - ) - - playlist = SpotifyPlaylist( - id="test_id", - name="Test Playlist", - tracks=[track], - url="https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", - description="Test description", - owner="Test User", - ) - - m3u_content = playlist.to_m3u() - - assert "#EXTM3U" in m3u_content - assert "Test Playlist" in m3u_content - assert "Test User" in m3u_content - assert "#EXTINF:180,Test Artist - Test Album - Test Song" in m3u_content - assert "https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh" in m3u_content - - -@patch("guhcampos.spotify.client.SpotifyAPI") -def test_fetch_spotify_playlists_success(mock_spotify_api, tmp_path): - """Test fetch_spotify_playlists with successful API calls.""" - mock_api_instance = Mock() - mock_api_instance.authenticate.return_value = True - - # Mock playlist - mock_playlist = SpotifyPlaylist( - id="1", - name="Test Playlist", - tracks=[], - url="https://open.spotify.com/playlist/1", - description="Test description", - owner="Test User", - ) - - mock_api_instance.fetch_playlists.return_value = [mock_playlist] - mock_api_instance.get_playlist_tracks.return_value = [] - mock_spotify_api.return_value = mock_api_instance - - result = fetch_spotify_playlists( - ["Test Playlist"], - tmp_path, # Use pytest's tmp_path fixture - "test_user_id", - "test_client_id", - "test_client_secret", - ) - - assert result is True - mock_api_instance.authenticate.assert_called_once() - mock_api_instance.fetch_playlists.assert_called_once_with("test_user_id") - - -@patch("guhcampos.spotify.client.SpotifyAPI") -def test_list_spotify_playlists_success(mock_spotify_api): - """Test list_spotify_playlists with successful API calls.""" - mock_api_instance = Mock() - mock_api_instance.authenticate.return_value = True - - mock_playlist = SpotifyPlaylist( - id="1", - name="Test Playlist", - tracks=[], - url="https://open.spotify.com/playlist/1", - description="Test description", - owner="Test User", - ) - - mock_api_instance.fetch_playlists.return_value = [mock_playlist] - mock_spotify_api.return_value = mock_api_instance - - result = list_spotify_playlists( - "test_user", - "test_client_id", - "test_client_secret", - ) - - assert result is True - mock_api_instance.authenticate.assert_called_once() - mock_api_instance.fetch_playlists.assert_called_once_with("test_user") From 965eb3b4fe8821e0e811a9520e1d2c3c51fd223b Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Tue, 15 Jul 2025 11:34:14 -0300 Subject: [PATCH 10/16] adding data adapter for spotify playlists --- hugo/config/_default/hugo.toml | 6 +- hugo/config/_default/menus.en.toml | 21 +- hugo/config/_default/menus.pt-br.toml | 21 +- hugo/config/_default/module.toml | 4 + hugo/content/en/about.md | 6 +- hugo/content/en/playlists/_content.gotmpl | 49 +- hugo/content/en/playlists/_index.md | 2 + hugo/content/pt/about.md | 5 +- hugo/content/pt/playlists/_index.md | 13 +- .../layouts/_partials/under-construction.html | 15 - .../layouts/_shortcodes/playlist-display.html | 555 +++++++++++++----- hugo/layouts/_shortcodes/playlists-index.html | 354 ++++++++--- hugo/layouts/_shortcodes/render-partial.html | 1 - 13 files changed, 758 insertions(+), 294 deletions(-) delete mode 100644 hugo/layouts/_partials/under-construction.html delete mode 100644 hugo/layouts/_shortcodes/render-partial.html diff --git a/hugo/config/_default/hugo.toml b/hugo/config/_default/hugo.toml index 67e42f3..7baa66e 100644 --- a/hugo/config/_default/hugo.toml +++ b/hugo/config/_default/hugo.toml @@ -15,11 +15,15 @@ summaryLength = 0 path = "blowfish" disable = false + [[module.mounts]] + source = 'static/playlists' + target = 'data/playlists' + includeFiles = ['*.json'] + [pagination] pagerSize = 10 [taxonomies] - category = "categories" series = "series" tag = "tags" topic = "topics" diff --git a/hugo/config/_default/menus.en.toml b/hugo/config/_default/menus.en.toml index e01820b..edb874c 100644 --- a/hugo/config/_default/menus.en.toml +++ b/hugo/config/_default/menus.en.toml @@ -3,15 +3,15 @@ name = "Blog" pageRef = "posts" weight = 10 -# [[main]] -# name = "Music" -# weight = 20 +[[main]] + name = "Music" + weight = 20 -# [[main]] -# name = "Playlists" -# parent = "Music" -# pageRef = "playlists" -# weight = 30 +[[main]] +name = "Playlists" +parent = "Music" +pageRef = "playlists" +weight = 30 # [[main]] # name = "Resources" @@ -31,7 +31,6 @@ pageRef = "tags" weight = 10 [[footer]] -name = "Categories" -pageRef = "categories" +name = "Topics" +pageRef = "topics" weight = 20 - diff --git a/hugo/config/_default/menus.pt-br.toml b/hugo/config/_default/menus.pt-br.toml index 85bb3d0..dbb5133 100644 --- a/hugo/config/_default/menus.pt-br.toml +++ b/hugo/config/_default/menus.pt-br.toml @@ -1,18 +1,17 @@ - [[main]] name = "Blog" pageRef = "posts" weight = 10 -# [[main]] -# name = "Música" -# weight = 20 +[[main]] + name = "Música" + weight = 20 -# [[main]] -# name = "Playlists" -# parent = "Música" -# pageRef = "playlists" -# weight = 20 +[[main]] +name = "Playlists" +parent = "Música" +pageRef = "playlists" +weight = 20 # [[main]] # name = "Recursos" @@ -32,6 +31,6 @@ pageRef = "tags" weight = 10 [[footer]] -name = "Categorias" -pageRef = "categorias" +name = "Tópicos" +pageRef = "topics" weight = 20 diff --git a/hugo/config/_default/module.toml b/hugo/config/_default/module.toml index 1feb525..4c30d53 100644 --- a/hugo/config/_default/module.toml +++ b/hugo/config/_default/module.toml @@ -2,6 +2,10 @@ extended = false min = "0.87.0" +[[module.imports]] + path = "blowfish" + disable = false + [[module.mounts]] source = 'static/playlists' target = 'data/playlists' diff --git a/hugo/content/en/about.md b/hugo/content/en/about.md index 22d55f8..ee04889 100644 --- a/hugo/content/en/about.md +++ b/hugo/content/en/about.md @@ -1,6 +1,8 @@ --- title: About Me showDate: false ---- +showAuthor: false +showReadingTime: false +showWordCount: false -## About Me +--- diff --git a/hugo/content/en/playlists/_content.gotmpl b/hugo/content/en/playlists/_content.gotmpl index d82853a..6c38d82 100644 --- a/hugo/content/en/playlists/_content.gotmpl +++ b/hugo/content/en/playlists/_content.gotmpl @@ -1,38 +1,33 @@ {{ .EnableAllLanguages }} -{{ $data := .Site.Data.books }} -{{ range $data }} +{{ $playlists := .Site.Data.playlists }} +{{ range $playlist_id, $playlist_data := $playlists }} + + {{ $content := printf "{{< playlist-display playlist_id=\"%s\" >}}" $playlist_id }} + {{ $content := dict "mediaType" "text/markdown" "value" $content }} + + {{ $dates := dict "date" (time.AsTime "2024-01-01") }} + + {{ $total_duration := 0 }} + {{ range $playlist_data.tracks }} + {{ $total_duration = add $total_duration .duration_ms }} + {{ end }} + + {{ $params := dict + "playlist_id" $playlist_id + "title" $playlist_data.name + "description" $playlist_data.description + "tracks_count" (len $playlist_data.tracks) + "duration_ms" $total_duration + }} - {{ $content := dict "mediaType" "text/markdown" "value" .summary }} - {{ $dates := dict "date" (time.AsTime .date) }} - {{ $params := dict "author" .author "isbn" .isbn "rating" .rating "tags" .tags }} {{ $page := dict "content" $content "dates" $dates "kind" "page" "params" $params - "path" .title - "title" .title + "path" $playlist_id + "title" $playlist_data.name }} {{ $.AddPage $page }} - {{ $item := . }} - {{ with $url := $item.cover }} - {{ with try (resources.GetRemote $url) }} - {{ with .Err }} - {{ errorf "Unable to get remote resource %s: %s" $url . }} - {{ else with .Value }} - {{ $content := dict "mediaType" .MediaType.Type "value" .Content }} - {{ $params := dict "alt" $item.title }} - {{ $resource := dict - "content" $content - "params" $params - "path" (printf "%s/cover.%s" $item.title .MediaType.SubType) - }} - {{ $.AddResource $resource }} - {{ else }} - {{ errorf "Unable to get remote resource %s" $url }} - {{ end }} - {{ end }} - {{ end }} - {{ end }} diff --git a/hugo/content/en/playlists/_index.md b/hugo/content/en/playlists/_index.md index c792a6a..8b1be8f 100644 --- a/hugo/content/en/playlists/_index.md +++ b/hugo/content/en/playlists/_index.md @@ -10,3 +10,5 @@ else out there. Having them here also helps me not be a prisioner of any one music listening service: as I have them all in JSON and M3U formats, I can easily migrate them between different services if I so wish to. + +{{< playlists-index >}} diff --git a/hugo/content/pt/about.md b/hugo/content/pt/about.md index d5ae68a..ffccb25 100644 --- a/hugo/content/pt/about.md +++ b/hugo/content/pt/about.md @@ -1,6 +1,7 @@ --- title: Sobre Mim showDate: false +showAuthor: false +showReadingTime: false +showWordCount: false --- - -## Sobre Mim diff --git a/hugo/content/pt/playlists/_index.md b/hugo/content/pt/playlists/_index.md index 48a3dcb..e3cc4c2 100644 --- a/hugo/content/pt/playlists/_index.md +++ b/hugo/content/pt/playlists/_index.md @@ -2,4 +2,15 @@ title: Playlists --- -Minhas Playlists +A vida inteira eu escutei música no shuffle. Salvo raros álbums que precisam ser +escutados em sua *completude*, eu prefiro ouvir minhas músicas favoritas de +múltiplos artistas do que maratonar um único artista o dia todo. Por conta disso, +tenho o hábito de criar playlists com certas temáticas específicas pra momentos +específicos, e decidi que era uma boa ideia compartilhar com todo mundo. + +Uma vantagem de ter essas playlists exportadas aqui é que eu passo a ter esses +dados em formato JSON e M3U, facilitando a importação desses dados em outras +plataformas, assim não fico preso a uma plataforma tipo Spotify, impedido de +mudar pra não perder minhas amadas playlists. + +{{< playlists-index >}} diff --git a/hugo/layouts/_partials/under-construction.html b/hugo/layouts/_partials/under-construction.html deleted file mode 100644 index e98d3e6..0000000 --- a/hugo/layouts/_partials/under-construction.html +++ /dev/null @@ -1,15 +0,0 @@ -{{/* Under Construction Banner */}} -
-
-
- - - -

- Under Construction - -

-
-
-
-
diff --git a/hugo/layouts/_shortcodes/playlist-display.html b/hugo/layouts/_shortcodes/playlist-display.html index 759d49d..cb21bb8 100644 --- a/hugo/layouts/_shortcodes/playlist-display.html +++ b/hugo/layouts/_shortcodes/playlist-display.html @@ -6,347 +6,594 @@ {{ range $playlist_data.tracks }} {{ $total_duration = add $total_duration (div .duration_ms 1000) }} {{ end }} -
+ +
+
-

{{ $playlist_data.name }}

-

{{ $playlist_data.description }}

-
- Created by: {{ $playlist_data.owner }} - {{ len $playlist_data.tracks }} tracks - {{ printf "%.0f" (div $total_duration 60.0) }} minutes +
+

{{ $playlist_data.name }}

+ {{ if $playlist_data.description }} +

{{ $playlist_data.description }}

+ {{ end }} +
+ + + {{ $playlist_data.owner }} + + + + {{ len $playlist_data.tracks }} tracks + + + + {{ printf "%.0f" (div $total_duration 60.0) }} min + +
+
+
- -
+ +
- - - - - + + + + + - {{ range $playlist_data.tracks }} - - - - - - + + + + + {{ end }}
ArtistAlbumTrackDurationActions + Artist + + + Album + + + Track + + + Duration + + Actions
{{ index .artists 0 }}{{ .album }}{{ .name }}{{ printf "%d:%02d" (div (div .duration_ms 1000) 60) (mod (div .duration_ms 1000) 60) }} - - + {{ range $index, $track := $playlist_data.tracks }} +
+ {{ index .artists 0 }} + + {{ .album }} + + {{ .name }} + + {{ printf "%d:%02d" (div (div .duration_ms 1000) 60) (mod (div .duration_ms 1000) 60) }} + + +
+ +
-{{ else }} -
-

Playlist data not found for ID: {{ $playlist_id }}

-
{{ end }} diff --git a/hugo/layouts/_shortcodes/playlists-index.html b/hugo/layouts/_shortcodes/playlists-index.html index 0ef265b..a5eead8 100644 --- a/hugo/layouts/_shortcodes/playlists-index.html +++ b/hugo/layouts/_shortcodes/playlists-index.html @@ -2,6 +2,7 @@ {{ $playlists_data := .Site.Data.playlists }}
+
{{ range $playlists }} {{ $playlist_id := .Params.playlist_id }} @@ -11,162 +12,377 @@ {{ range $playlist_data.tracks }} {{ $total_duration = add $total_duration (div .duration_ms 1000) }} {{ end }} -
+ +
-

{{ $playlist_data.name }}

-

{{ $playlist_data.description }}

+
+

{{ $playlist_data.name }}

+ {{ if $playlist_data.description }} +

{{ $playlist_data.description }}

+ {{ end }} +
+ +
+ {{ if $playlist_data.images }} + Playlist cover for {{ $playlist_data.name }} + {{ else }} +
+ +
+ {{ end }} +
+
- - - {{ $playlist_data.owner }} - - - - {{ len $playlist_data.tracks }} tracks - - - - {{ printf "%.0f" (div $total_duration 60.0) }} min - +
+ + {{ $playlist_data.owner }} +
+
+ + {{ len $playlist_data.tracks }} tracks +
+
+ + {{ printf "%.0f" (div $total_duration 60.0) }} min +
+ -
+ {{ end }} {{ end }}
+ + {{ if not $playlists }} +
+
+ +

No Playlists Yet

+

Playlists will appear here once they're added to the site.

+
+
+ {{ end }}
diff --git a/hugo/layouts/_shortcodes/render-partial.html b/hugo/layouts/_shortcodes/render-partial.html deleted file mode 100644 index c9d3496..0000000 --- a/hugo/layouts/_shortcodes/render-partial.html +++ /dev/null @@ -1 +0,0 @@ -{{ partial (.Get 0) . }} From 671a36f25b31acaca67963debc262d9fe1429090 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Wed, 16 Jul 2025 12:02:23 -0300 Subject: [PATCH 11/16] fixed the playlist view --- hugo/assets/css/custom.css | 3 + hugo/config/_default/languages.en.toml | 2 +- hugo/config/_default/languages.pt-br.toml | 4 +- hugo/config/_default/params.toml | 28 +- hugo/content/_index.md | 7 + hugo/content/en/playlists/_index.md | 13 + hugo/content/{pt => pt-br}/about.md | 0 .../content/{pt => pt-br}/playlists/_index.md | 13 + hugo/content/{pt => pt-br}/posts/_index.md | 0 .../layouts/_shortcodes/playlist-display.html | 726 +++++------------- hugo/layouts/_shortcodes/playlists-index.html | 536 ++++--------- hugo/layouts/playlists/list.html | 139 ++++ hugo/layouts/playlists/single.html | 176 +++++ src/guhcampos/settings.py | 1 + src/guhcampos/spotify/client.py | 14 +- 15 files changed, 735 insertions(+), 927 deletions(-) create mode 100644 hugo/assets/css/custom.css rename hugo/content/{pt => pt-br}/about.md (100%) rename hugo/content/{pt => pt-br}/playlists/_index.md (81%) rename hugo/content/{pt => pt-br}/posts/_index.md (100%) create mode 100644 hugo/layouts/playlists/list.html create mode 100644 hugo/layouts/playlists/single.html diff --git a/hugo/assets/css/custom.css b/hugo/assets/css/custom.css new file mode 100644 index 0000000..b9d7432 --- /dev/null +++ b/hugo/assets/css/custom.css @@ -0,0 +1,3 @@ +html { + font-size: 10pt; +} diff --git a/hugo/config/_default/languages.en.toml b/hugo/config/_default/languages.en.toml index 89dbf76..22344f7 100644 --- a/hugo/config/_default/languages.en.toml +++ b/hugo/config/_default/languages.en.toml @@ -1,7 +1,7 @@ contentDir = "content/en" languageCode = "en" languageName = "English" -title = "guhcampos en" +title = "guhcampos" weight = 1 [params] diff --git a/hugo/config/_default/languages.pt-br.toml b/hugo/config/_default/languages.pt-br.toml index 31a54a9..bfe0bef 100644 --- a/hugo/config/_default/languages.pt-br.toml +++ b/hugo/config/_default/languages.pt-br.toml @@ -1,7 +1,7 @@ -contentDir = "content/pt" +contentDir = "content/pt-br" languageCode = "pt-br" languageName = "Português" -title = "guhcampos br" +title = "guhcampos" weight = 2 [params] diff --git a/hugo/config/_default/params.toml b/hugo/config/_default/params.toml index 045653f..9602204 100644 --- a/hugo/config/_default/params.toml +++ b/hugo/config/_default/params.toml @@ -51,28 +51,28 @@ cardViewScreenWidth = false layoutBackgroundBlur = false [article] -showDate = true -showViews = false -showLikes = false -showDateOnlyInArticle = false -showDateUpdated = false -showAuthor = true -showHero = false +editAppendPath = true +invertPagination = false layoutBackgroundBlur = true layoutBackgroundHeaderSpace = true +seriesOpened = true +showAuthor = false +showAuthorsBadges = true showBreadcrumbs = false +showCategoryOnly = false +showDate = true +showDateOnlyInArticle = true +showDateUpdated = true showDraftLabel = true showEdit = false -editAppendPath = true -seriesOpened = false showHeadingAnchors = true +showHero = false +showLikes = false showPagination = true -invertPagination = false showReadingTime = true showTableOfContents = false -showTaxonomies = false -showCategoryOnly = false -showAuthorsBadges = false +showTaxonomies = true +showViews = false showWordCount = true showZenMode = false @@ -85,7 +85,7 @@ showSummary = false showViews = false showLikes = false showTableOfContents = false -showCards = false +showCards = true orderByWeight = false groupByYear = true cardView = false diff --git a/hugo/content/_index.md b/hugo/content/_index.md index e69de29..177d4a8 100644 --- a/hugo/content/_index.md +++ b/hugo/content/_index.md @@ -0,0 +1,7 @@ +--- +showDate: false +showAuthor: false +showReadingTime: false +showWordCount: false +# showRecent: false +--- diff --git a/hugo/content/en/playlists/_index.md b/hugo/content/en/playlists/_index.md index 8b1be8f..f874a92 100644 --- a/hugo/content/en/playlists/_index.md +++ b/hugo/content/en/playlists/_index.md @@ -1,5 +1,18 @@ --- title: Playlists + +cascade: + params: + showRecent: false + showAuthor: false + showDate: false + showWordCount: false + showReadingTime: false + +tags: + - music +topics: + - music --- I'm a shuffle type of music listener, and as such I make heavy use of playlists diff --git a/hugo/content/pt/about.md b/hugo/content/pt-br/about.md similarity index 100% rename from hugo/content/pt/about.md rename to hugo/content/pt-br/about.md diff --git a/hugo/content/pt/playlists/_index.md b/hugo/content/pt-br/playlists/_index.md similarity index 81% rename from hugo/content/pt/playlists/_index.md rename to hugo/content/pt-br/playlists/_index.md index e3cc4c2..338d274 100644 --- a/hugo/content/pt/playlists/_index.md +++ b/hugo/content/pt-br/playlists/_index.md @@ -1,5 +1,18 @@ --- title: Playlists + +cascade: + params: + showRecent: false + showAuthor: false + showDate: false + showWordCount: false + showReadingTime: false + +tags: + - music +topics: + - music --- A vida inteira eu escutei música no shuffle. Salvo raros álbums que precisam ser diff --git a/hugo/content/pt/posts/_index.md b/hugo/content/pt-br/posts/_index.md similarity index 100% rename from hugo/content/pt/posts/_index.md rename to hugo/content/pt-br/posts/_index.md diff --git a/hugo/layouts/_shortcodes/playlist-display.html b/hugo/layouts/_shortcodes/playlist-display.html index cb21bb8..0fdb9bf 100644 --- a/hugo/layouts/_shortcodes/playlist-display.html +++ b/hugo/layouts/_shortcodes/playlist-display.html @@ -4,595 +4,273 @@ {{ if $playlist_data }} {{ $total_duration := 0 }} {{ range $playlist_data.tracks }} - {{ $total_duration = add $total_duration (div .duration_ms 1000) }} + {{ $total_duration = add $total_duration .duration_ms }} {{ end }} +{{ $duration_minutes := div $total_duration 60000 | int }} -
- -
-
-

{{ $playlist_data.name }}

- {{ if $playlist_data.description }} -

{{ $playlist_data.description }}

- {{ end }} -
- - - {{ $playlist_data.owner }} - - - - {{ len $playlist_data.tracks }} tracks - - - - {{ printf "%.0f" (div $total_duration 60.0) }} min - -
-
- -
- -
-
- - -
-
- - -
-
- -
- - - - - - - - - - - - {{ range $index, $track := $playlist_data.tracks }} - - - - - - - - {{ end }} - -
- Artist - - - Album - - - Track - - - Duration - - Actions
- {{ index .artists 0 }} - - {{ .album }} - - {{ .name }} - - {{ printf "%d:%02d" (div (div .duration_ms 1000) 60) (mod (div .duration_ms 1000) 60) }} - - - - -
- - -
-
+
- +
+
+ + + + + + + + + + + {{ range $index, $track := $playlist_data.tracks }} + {{ $track_duration_seconds := div .duration_ms 1000 }} + {{ $minutes := div $track_duration_seconds 60 | int }} + {{ $seconds := mod $track_duration_seconds 60 | int }} + + + + + + + {{ end }} + +
+ {{ i18n "playlists.title" | default "Title" }} + + + + + {{ i18n "playlists.artist" | default "Artist" }} + + + + + {{ i18n "playlists.album" | default "Album" }} + + + + + {{ i18n "playlists.duration" | default "Time" }} + + + +
+
{{ .name }}
+
+
{{ index .artists 0 }}
+
+
{{ .album }}
+
+ {{ printf "%d:%02d" $minutes $seconds }} +
+
+
+ */}} + + + +
diff --git a/hugo/layouts/_shortcodes/playlists-index.html b/hugo/layouts/_shortcodes/playlists-index.html index a5eead8..dd97448 100644 --- a/hugo/layouts/_shortcodes/playlists-index.html +++ b/hugo/layouts/_shortcodes/playlists-index.html @@ -1,388 +1,166 @@ + + {{ $playlists := where .Site.RegularPages "Section" "playlists" }} {{ $playlists_data := .Site.Data.playlists }} -
- -
- {{ range $playlists }} - {{ $playlist_id := .Params.playlist_id }} - {{ $playlist_data := index $playlists_data $playlist_id }} - {{ if $playlist_data }} - {{ $total_duration := 0 }} - {{ range $playlist_data.tracks }} - {{ $total_duration = add $total_duration (div .duration_ms 1000) }} +
+
+ {{/* Pin 'This is Guhcampos' first if it exists */}} + {{ $pinned_id := "this-is-guhcampos" }} + {{ $pinned := where $playlists "Params.playlist_id" $pinned_id | first 1 }} + {{ with index $pinned 0 }} + {{ $playlist_id := .Params.playlist_id }} + {{ if and $playlist_id (index $playlists_data $playlist_id) }} + {{ $playlist_data := index $playlists_data $playlist_id }} + {{ $total_duration := 0 }} + {{ range $playlist_data.tracks }} + {{ $total_duration = add $total_duration .duration_ms }} + {{ end }} + {{ $duration_minutes := div $total_duration 60000 | int }} +
+
+
+
+

+ + {{ $playlist_data.name | emojify }} + +

+ {{ if $playlist_data.description }} +

+ {{ $playlist_data.description }} +

+ {{ end }} +
+
+
+ + + +
+
+
+
+
+ + + + + {{ len $playlist_data.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} + + + + + + {{ $duration_minutes }} {{ i18n "playlists.minutes" | default "min" }} + +
+
+ +
+
+ {{ end }} {{ end }} -
-
-
-

{{ $playlist_data.name }}

- {{ if $playlist_data.description }} -

{{ $playlist_data.description }}

+ {{/* Render all other playlists sorted by name, skipping the pinned one */}} + {{ $sorted := sort $playlists "Title" }} + {{ range $sorted }} + {{ if ne .Params.playlist_id $pinned_id }} + {{ $playlist_id := .Params.playlist_id }} + {{ if and $playlist_id (index $playlists_data $playlist_id) }} + {{ $playlist_data := index $playlists_data $playlist_id }} + {{ $total_duration := 0 }} + {{ range $playlist_data.tracks }} + {{ $total_duration = add $total_duration .duration_ms }} {{ end }} -
- -
- {{ if $playlist_data.images }} - Playlist cover for {{ $playlist_data.name }} - {{ else }} -
- -
- {{ end }} -
-
- -
-
- - {{ $playlist_data.owner }} -
-
- - {{ len $playlist_data.tracks }} tracks -
-
- - {{ printf "%.0f" (div $total_duration 60.0) }} min -
-
- - -
- {{ end }} + {{ $duration_minutes := div $total_duration 60000 | int }} +
+
+
+
+

+ + {{ $playlist_data.name | emojify }} + +

+ {{ if $playlist_data.description }} +

+ {{ $playlist_data.description }} +

+ {{ end }} +
+
+
+ + + +
+
+
+
+
+ + + + + {{ len $playlist_data.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} + + + + + + {{ $duration_minutes }} {{ i18n "playlists.minutes" | default "min" }} + +
+
+ +
+
+ {{ end }} + {{ end }} {{ end }} -
- - {{ if not $playlists }} -
-
- -

No Playlists Yet

-

Playlists will appear here once they're added to the site.

-
-
- {{ end }} +
- diff --git a/hugo/layouts/playlists/list.html b/hugo/layouts/playlists/list.html new file mode 100644 index 0000000..db52acd --- /dev/null +++ b/hugo/layouts/playlists/list.html @@ -0,0 +1,139 @@ +{{ define "main" }} + +{{ .Scratch.Set "scope" "list" }} +{{ if .Site.Params.list.showHero | default false }} +{{ $heroStyle := print "hero/" .Site.Params.list.heroStyle ".html" }} +{{ if templates.Exists ( printf "partials/%s" $heroStyle ) }} +{{ partial $heroStyle . }} +{{ else }} +{{ partial "hero/basic.html" . }} +{{ end }} +{{- end -}} + +{{ $toc := and (.Params.showTableOfContents | default (.Site.Params.list.showTableOfContents | default false)) (in +.TableOfContents " + {{ if .Params.showBreadcrumbs | default (.Site.Params.list.showBreadcrumbs | default false) }} + {{ partial "breadcrumbs.html" . }} + {{ end }} +

{{ .Title }}

+
+ {{ partial "article-meta/list.html" (dict "context" . "scope" "single") }} +
+ {{ $translations := .AllTranslations }} + {{ with .File }} + {{ $path := .Path }} + {{range $translations}} + {{ $lang := print "." .Lang ".md" }} + {{ $path = replace $path $lang ".md" }} + {{end}} + {{ $jsPage := resources.Get "js/page.js" }} + {{ $jsPage = $jsPage | resources.Minify | resources.Fingerprint ($.Site.Params.fingerprintAlgorithm | default "sha512") }} + + {{ end }} + +
+ {{ if $toc }} +
+
+ {{ partial "toc.html" . }} +
+
+ {{ end }} +
+ {{ .Content }} +
+ + + + +
+ {{ if gt .Pages 0 }} + + {{ $cardView := .Params.cardView | default (.Site.Params.list.cardView | default false) }} + {{ $cardViewScreenWidth := .Params.cardViewScreenWidth | default (.Site.Params.list.cardViewScreenWidth | default false) }} + {{ $groupByYear := .Params.groupByYear | default ($.Site.Params.list.groupByYear | default false) }} + {{ $orderByWeight := .Params.orderByWeight | default ($.Site.Params.list.orderByWeight | default false) }} + {{ $groupByYear := and (not $orderByWeight) $groupByYear }} + + {{ if not $cardView }} + +
+ {{ if not $orderByWeight }} + {{ range (.Paginate (.Pages.GroupByDate "2006")).PageGroups }} + {{ if $groupByYear }} +

+ {{ .Key }} +

+ {{ end }} + {{ range .Pages }} + {{ partial "article-link/simple.html" . }} + {{ end }} + {{ end }} + {{ else }} + {{ range (.Paginate (.Pages.ByWeight)).Pages }} + {{ partial "article-link/simple.html" . }} + {{ end }} + {{ end }} +
+ + {{ else }} + + {{ if $groupByYear }} + + {{ range (.Paginate (.Pages.GroupByDate "2006")).PageGroups }} + {{ if $cardViewScreenWidth }} +
+ {{ end }} +

+ {{ .Key }} +

+
+ {{ range .Pages }} + {{ partial "article-link/card.html" . }} + {{ end }} +
+ {{ if $cardViewScreenWidth }}
{{ end }} + {{ end }} + + {{ else }} + + {{ if $cardViewScreenWidth }} +
+
+ {{ else }} +
+ {{ end }} + {{ if not $orderByWeight }} + {{ range (.Paginate (.Pages.GroupByDate "2006")).PageGroups }} + {{ range .Pages }} + {{ partial "article-link/card.html" . }} + {{ end }} + {{ end }} + {{ else }} + {{ range (.Paginate (.Pages.ByWeight)).Pages }} + {{ partial "article-link/card.html" . }} + {{ end }} + {{ end }} +
+ {{ if $cardViewScreenWidth }}
{{ end }} + + {{ end }} + + {{end}} + + {{ else }} +
+

+ {{ i18n "list.no_articles" | emojify }} +

+
+ {{ end }} + + {{ partial "pagination.html" . }} + + {{ end }} diff --git a/hugo/layouts/playlists/single.html b/hugo/layouts/playlists/single.html new file mode 100644 index 0000000..7a0cf39 --- /dev/null +++ b/hugo/layouts/playlists/single.html @@ -0,0 +1,176 @@ +{{ define "main" }} + {{ .Scratch.Set "scope" "single" }} + + +
+ {{ if .Params.showHero | default (.Site.Params.article.showHero | default false) }} + {{ $heroStyle := .Params.heroStyle }} + {{ if not $heroStyle }}{{ $heroStyle = .Site.Params.article.heroStyle }}{{ end }} + {{ $heroStyle := print "hero/" $heroStyle ".html" }} + {{ if templates.Exists ( printf "partials/%s" $heroStyle ) }} + {{ partial $heroStyle . }} + {{ else }} + {{ partial "hero/basic.html" . }} + {{ end }} + {{ end }} + + +
+ {{ if .Params.showBreadcrumbs | default (.Site.Params.article.showBreadcrumbs | default false) }} + {{ partial "breadcrumbs.html" . }} + {{ end }} +

+ {{ .Title | emojify }} +

+
+ {{ partial "article-meta/basic.html" (dict "context" . "scope" "single") }} +
+ + {{ $authorsData := .Site.Data.authors }} + {{ $taxonomies := .Site.Taxonomies.authors }} + {{ $baseURL := .Site.BaseURL }} + {{ $taxonomyLink := 0 }} + {{ $showAuthor := 0 }} + + {{ if not (strings.HasSuffix $baseURL "/") }} + {{ $baseURL = delimit (slice $baseURL "/") "" }} + {{ end }} + + {{ if not (.Params.showAuthorBottom | default ( .Site.Params.article.showAuthorBottom | default false)) }} + + {{ if .Params.showAuthor | default (.Site.Params.article.showAuthor | default true) }} + {{ $showAuthor = 1 }} + {{ partial "author.html" . }} + {{ end }} + + {{ range $author := .Page.Params.authors }} + {{ $authorData := index $authorsData $author }} + {{- if $authorData -}} + {{ range $taxonomyname, $taxonomy := $taxonomies }} + {{ if (eq $taxonomyname $author) }} + {{ $taxonomyLink = delimit (slice $baseURL "authors/" $author "/") "" }} + {{ end }} + {{ end }} + + {{ $finalLink := $taxonomyLink }} + {{ $currentLang := $.Site.Language.Lang }} + {{ if eq $.Site.LanguagePrefix "" }} + {{ $finalLink = printf "%sauthors/%s/" $baseURL $author }} + {{ else }} + {{ $finalLink = printf "%s%s/authors/%s/" $baseURL $currentLang $author }} + {{ end }} + + {{ partial "author-extra.html" (dict "context" . "data" $authorData "link" $finalLink) }} + {{- end -}} + {{ end }} + + {{ if or $taxonomyLink $showAuthor }} +
+ {{ end }} + + {{ end }} + +
+ +
+ {{ if or (and (.Params.showTableOfContents | default (.Site.Params.article.showTableOfContents | default false)) (in + .TableOfContents " +
+ {{ if and (.Params.showTableOfContents | default (.Site.Params.article.showTableOfContents | default false)) (in + .TableOfContents " +
+ {{ end }} + + +
+ {{ partial "series/series.html" . }} + + +
+ {{ .Content }} + {{ $defaultReplyByEmail := .Site.Params.replyByEmail }} + {{ $replyByEmail := default $defaultReplyByEmail .Params.replyByEmail }} +
+ + {{ if (.Params.showAuthorBottom | default ( .Site.Params.article.showAuthorBottom | default false)) }} + + {{ if .Params.showAuthor | default (.Site.Params.article.showAuthor | default true) }} + {{ $showAuthor = 1 }} + {{ partial "author.html" . }} + {{ end }} + + {{ range $author := .Page.Params.authors }} + {{ $authorData := index $authorsData $author }} + {{- if $authorData -}} + {{ range $taxonomyname, $taxonomy := $taxonomies }} + {{ if (eq $taxonomyname $author) }} + {{ $taxonomyLink = delimit (slice $baseURL "authors/" $author "/") "" }} + {{ end }} + {{ end }} + {{ partial "author-extra.html" (dict "context" . "data" $authorData "link" $taxonomyLink) }} + {{- end -}} + {{ end }} + + {{ if or $taxonomyLink $showAuthor }} +
+ {{ end }} + + {{ end }} + + {{ partial "series/series-closed.html" . }} + {{ partial "sharing-links.html" . }} + {{ partial "related.html" . }} +
+ + {{ $translations := .AllTranslations }} + {{ with .File }} + {{ $path := .Path }} + {{ range $translations }} + {{ $lang := print "." .Lang ".md" }} + {{ $path = replace $path $lang ".md" }} + {{ end }} + {{ $jsPage := resources.Get "js/page.js" }} + {{ $jsPage = $jsPage | resources.Minify | resources.Fingerprint ($.Site.Params.fingerprintAlgorithm | default "sha512") }} + + {{ end }} + +
+
+ {{ partial "article-pagination.html" . }} + {{ if .Params.showComments | default (.Site.Params.article.showComments | default false) }} + {{ if templates.Exists "partials/comments.html" }} +
+
+
+ {{ partial "comments.html" . }} +
+
+ {{ else }} + {{ warnf "[BLOWFISH] Comments are enabled for %s but no comments partial exists." .File.Path }} + {{ end }} + {{ end }} +
+
+{{ end }} diff --git a/src/guhcampos/settings.py b/src/guhcampos/settings.py index 6fc4961..1d5e55a 100644 --- a/src/guhcampos/settings.py +++ b/src/guhcampos/settings.py @@ -17,6 +17,7 @@ class Settings(BaseSettings): spotify_playlists: list[str] = [ "This is Guhcampos", "01.01 Beard Growing", + "01.04 Banda Indie Canta Hey!", ] spotify_playlists_output_dir: Path = REPO_ROOT / "hugo" / "static" / "playlists" diff --git a/src/guhcampos/spotify/client.py b/src/guhcampos/spotify/client.py index 54d7550..57861c9 100644 --- a/src/guhcampos/spotify/client.py +++ b/src/guhcampos/spotify/client.py @@ -1,4 +1,5 @@ import base64 +import html from collections.abc import Generator from pathlib import Path @@ -7,10 +8,10 @@ from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn -console = Console() - from .models import SpotifyPlaylist, SpotifyTrack +console = Console() + class SpotifyAPI: def __init__(self, client_id: str, client_secret: str): @@ -71,17 +72,16 @@ def fetch_playlists(self, user_id: str) -> Generator[SpotifyPlaylist, None, None if not playlists: break - yield from [ - SpotifyPlaylist( - description=playlist["description"], + for playlist in playlists: + console.print(playlist) + yield SpotifyPlaylist( + description=html.unescape(playlist["description"]), owner=playlist["owner"]["display_name"], url=playlist["external_urls"]["spotify"], id=playlist["id"], name=playlist["name"], tracks=[], # playlist["tracks"], ) - for playlist in playlists - ] if len(playlists) < limit: break From 1be0b2382a057c89b96ef6f5fec7f55f2e5f8fb5 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Thu, 17 Jul 2025 00:00:19 -0300 Subject: [PATCH 12/16] adjusting visualizations and playing with blogging format --- .github/workflows/hugo.yml | 4 +- hugo/assets/css/custom.css | 9 +- hugo/config/_default/hugo.toml | 2 +- hugo/config/_default/menus.en.toml | 12 +- hugo/config/_default/menus.pt-br.toml | 13 +- hugo/config/_default/module.toml | 13 +- hugo/config/_default/params.toml | 16 +- hugo/content/en/playlists/_content.gotmpl | 8 +- hugo/content/en/playlists/_index.md | 7 +- ...design-this-website-part-01-first-steps.md | 103 ++++++ hugo/content/pt-br/playlists/_index.md | 1 - hugo/layouts/_shortcodes/playlists-index.html | 166 ---------- .../partials/article-link/playlist-card.html | 70 ++++ hugo/layouts/playlists/list.html | 27 +- .../playlist.html} | 93 ++---- hugo/layouts/playlists/single.html | 201 ++++++++--- pyproject.toml | 2 + src/guhcampos/README.md | 22 ++ src/guhcampos/cli.py | 17 + src/guhcampos/logging.py | 12 + src/guhcampos/obsidian.py | 0 src/guhcampos/obsidian/client.py | 79 +++++ src/guhcampos/obsidian/errors.py | 2 + src/guhcampos/obsidian/models.py | 41 +++ src/guhcampos/settings.py | 10 + src/guhcampos/spotify/client.py | 1 - src/guhcampos/utils.py | 5 + src/sync-obsidian | 313 ------------------ uv.lock | 52 +++ 29 files changed, 647 insertions(+), 654 deletions(-) create mode 100644 hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md delete mode 100644 hugo/layouts/_shortcodes/playlists-index.html create mode 100644 hugo/layouts/partials/article-link/playlist-card.html rename hugo/layouts/{_shortcodes/playlist-display.html => playlists/playlist.html} (60%) create mode 100644 src/guhcampos/README.md create mode 100644 src/guhcampos/logging.py delete mode 100644 src/guhcampos/obsidian.py create mode 100644 src/guhcampos/obsidian/client.py create mode 100644 src/guhcampos/obsidian/errors.py create mode 100644 src/guhcampos/obsidian/models.py create mode 100644 src/guhcampos/utils.py delete mode 100755 src/sync-obsidian diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index 327697f..44c572a 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -44,9 +44,9 @@ jobs: run: uv python install - name: install hugo - uses: peaceiris/actions-hugo@v2 + uses: peaceiris/actions-hugo@v3 with: - hugo-version: '0.147.7' + hugo-version: '0.148.1' extended: true - name: install python dependencies diff --git a/hugo/assets/css/custom.css b/hugo/assets/css/custom.css index b9d7432..fa129dd 100644 --- a/hugo/assets/css/custom.css +++ b/hugo/assets/css/custom.css @@ -1,3 +1,8 @@ -html { - font-size: 10pt; +/* html { + font-size: 10pt; } + +body * { + text-align: justify; + justify-content: center; +} */ diff --git a/hugo/config/_default/hugo.toml b/hugo/config/_default/hugo.toml index 7baa66e..584b1ee 100644 --- a/hugo/config/_default/hugo.toml +++ b/hugo/config/_default/hugo.toml @@ -26,7 +26,7 @@ summaryLength = 0 [taxonomies] series = "series" tag = "tags" - topic = "topics" + # topic = "topics" [outputs] diff --git a/hugo/config/_default/menus.en.toml b/hugo/config/_default/menus.en.toml index edb874c..ad5c939 100644 --- a/hugo/config/_default/menus.en.toml +++ b/hugo/config/_default/menus.en.toml @@ -13,10 +13,6 @@ parent = "Music" pageRef = "playlists" weight = 30 -# [[main]] -# name = "Resources" -# pageRef = "resources" -# weight = 40 [[main]] name = "About" @@ -30,7 +26,7 @@ name = "Tags" pageRef = "tags" weight = 10 -[[footer]] -name = "Topics" -pageRef = "topics" -weight = 20 +# [[footer]] +# name = "Topics" +# pageRef = "topics" +# weight = 20 diff --git a/hugo/config/_default/menus.pt-br.toml b/hugo/config/_default/menus.pt-br.toml index dbb5133..2b1e44b 100644 --- a/hugo/config/_default/menus.pt-br.toml +++ b/hugo/config/_default/menus.pt-br.toml @@ -13,11 +13,6 @@ parent = "Música" pageRef = "playlists" weight = 20 -# [[main]] -# name = "Recursos" -# pageRef = "recursos" -# weight = 20 - [[main]] name = "Sobre" pageRef = "about" @@ -30,7 +25,7 @@ name = "Tags" pageRef = "tags" weight = 10 -[[footer]] -name = "Tópicos" -pageRef = "topics" -weight = 20 +# [[footer]] +# name = "Tópicos" +# pageRef = "topics" +# weight = 20 diff --git a/hugo/config/_default/module.toml b/hugo/config/_default/module.toml index 4c30d53..5941348 100644 --- a/hugo/config/_default/module.toml +++ b/hugo/config/_default/module.toml @@ -1,6 +1,6 @@ [hugoVersion] extended = false - min = "0.87.0" + min = "0.148.0" [[module.imports]] path = "blowfish" @@ -25,3 +25,14 @@ lang = 'pt' source = 'content/en' target = 'content' lang = 'pt' + + +[[module.mounts]] + source = "build/obsidian/en/posts" + target = "content/en/posts" + includeFiles = ['*.md'] + +[[module.mounts]] + source = "build/obsidian/pt/posts]" + target = "content/pt/posts" + includeFiles = ['*.md'] diff --git a/hugo/config/_default/params.toml b/hugo/config/_default/params.toml index 9602204..e5460ab 100644 --- a/hugo/config/_default/params.toml +++ b/hugo/config/_default/params.toml @@ -1,8 +1,8 @@ -colorScheme = "blowfish" +colorScheme = "ocean" defaultAppearance = "light" autoSwitchAppearance = true enableSearch = true -enableCodeCopy = false +enableCodeCopy = true replyByEmail = false mainSections = [ "Resources", "Topics", "Lists", "Tutorials" ] disableImageOptimization = false @@ -31,7 +31,7 @@ spotify = "https://open.spotify.com/user/12132336829" steam = "https://steamcommunity.com/id/guhcampos/" [header] -layout = "basic" +layout = "fixed-fill-blur" [footer] showMenu = true @@ -66,7 +66,7 @@ showDateUpdated = true showDraftLabel = true showEdit = false showHeadingAnchors = true -showHero = false +showHero = true showLikes = false showPagination = true showReadingTime = true @@ -77,7 +77,7 @@ showWordCount = true showZenMode = false [list] -showHero = false +showHero = true layoutBackgroundBlur = true layoutBackgroundHeaderSpace = true showBreadcrumbs = false @@ -88,7 +88,7 @@ showTableOfContents = false showCards = true orderByWeight = false groupByYear = true -cardView = false +cardView = true cardViewScreenWidth = false constrainItemsWidth = false @@ -97,7 +97,7 @@ excludedKinds = [ "taxonomy", "term" ] [taxonomy] showTermCount = true -showHero = false +showHero = true showBreadcrumbs = false showViews = false showLikes = false @@ -105,7 +105,7 @@ showTableOfContents = false cardView = false [term] -showHero = false +showHero = true showBreadcrumbs = false showViews = false showLikes = false diff --git a/hugo/content/en/playlists/_content.gotmpl b/hugo/content/en/playlists/_content.gotmpl index 6c38d82..f661e3f 100644 --- a/hugo/content/en/playlists/_content.gotmpl +++ b/hugo/content/en/playlists/_content.gotmpl @@ -2,8 +2,10 @@ {{ $playlists := .Site.Data.playlists }} {{ range $playlist_id, $playlist_data := $playlists }} - {{ $content := printf "{{< playlist-display playlist_id=\"%s\" >}}" $playlist_id }} - {{ $content := dict "mediaType" "text/markdown" "value" $content }} + {{ $content := dict + "mediaType" "text/markdown" + "value" "" + }} {{ $dates := dict "date" (time.AsTime "2024-01-01") }} @@ -18,6 +20,8 @@ "description" $playlist_data.description "tracks_count" (len $playlist_data.tracks) "duration_ms" $total_duration + "tracks" $playlist_data.tracks + "url" $playlist_data.url }} {{ $page := dict diff --git a/hugo/content/en/playlists/_index.md b/hugo/content/en/playlists/_index.md index f874a92..98459d1 100644 --- a/hugo/content/en/playlists/_index.md +++ b/hugo/content/en/playlists/_index.md @@ -1,8 +1,9 @@ --- -title: Playlists - +title: Music Playlists +groupByYear: false cascade: params: + showRecent: false showAuthor: false showDate: false @@ -23,5 +24,3 @@ else out there. Having them here also helps me not be a prisioner of any one music listening service: as I have them all in JSON and M3U formats, I can easily migrate them between different services if I so wish to. - -{{< playlists-index >}} diff --git a/hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md b/hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md new file mode 100644 index 0000000..c31a89a --- /dev/null +++ b/hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md @@ -0,0 +1,103 @@ +--- +date: 2025-07-16 +title: Pairing with AI to Design This Website Part 01 - First Steps +tags: +- software +- ai +- engineering +- web-development +- tech + +--- +## Everything Begins with an Idea + +I'm a bit of a low-profile person on the Internet, which seems to be bad for your career these days, so after 15+ years without having a personal website or blog I decided that it was time to have one again. It does not help that social networks are in a bad shape in 2025, so having my own place to share some ideas and updates does not hurt either, so I started to build one. + +I had a few initial requirements, the first one being: I use [Obsidian](https://obsidian.md/) for my notes and would like to publish some of my Obsidian content into my website. I could use [Obsidian Sync](https://obsidian.md/sync) for that, but being in Brazil, adding expenses in USD to my budget is often a no-no. Besides, I wanted to have a bit of personality built into this, so I wanted a Static Site Generator to help me build the way I wanted. Having had some previous experience with [Hugo](https://gohugo.io/) a long time ago, I went wit it. Chose a [nice feature-rich theme](https://blowfish.page/) and fired VS Code. + +Then I froze. + +I'm a backend-devops-infra-data guy, and I haven't played with frontend code for a long, long time. I used to write a lot of HTML and CSS when [JQuery](https://jquery.com/) and [Lodash](https://lodash.com/) were the gold standard, but opening my theme's code felt completely alien to me. I'm sure there must be a reason why frontend folks love [TailwindCSS](https://tailwindcss.com/), but to my unaccustomed eyes that felt too big a grammar to learn just to put a website up. There was no way I was going to understand this just to build a blog: + +```html + +``` + +Then it came to me: _I'm going to AI the shit out of this._ + +I've been very slow to jump into the AI hype, as I do with most hype. After the first year or so of revolutionary claims and heated politics, the actual decent use cases started to emerge and I started to get curious about it. I tried generating some code here and there and found it to be lacking a bit compared to my own code skills - but here we are, faced with code I suck at writing, so why not give it a chance? + +So here we are. My very first article series in this website will be my attempt to log my process pairing with an AI to build this very website you're hopefully reading this article on the future. By process I mean obviously the successes and frustrations, and if the frustrations outweigh the successes too much there's a very real chance you'll never get to read this and I'm just talking to myself as usual. + +## Setting up + +I already had a stack to work on: **Hugo** to generate a static website and **Obsidian** as my main editor, so **Markdown** would be my content format of choice. I chose [Cursor](https://cursor.com/)as my editor and would leverage my Python background to write the code I needed to feed Hugo with stuff. + +First step, let's bring some Obsidian stuff into Hugo. I started writing a simple script to copy Obsidian Markdown files to my Hugo content directory, but this needed a bit of translation. Nothing complicated, but a bit boring, so I invited Cursor's agent for a chat and asked it to do it (sorry, I lost that prompt, it was before I thought about making this into a blog post) and it came up with [this script](https://github.com/guhcampos/guhcampos.github.io/blob/ab861cf75d9ae3025d6d66da1c33d4aaee21e108/scripts/sync-obsidian) + +As many before me, I got a little offended by its eagerness to over-explain every line of code: + +```python +def process_file(file_path: Path): + """Process a single markdown file.""" + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + frontmatter, content = extract_frontmatter(content) + + # Skip if not marked for publishing + if not frontmatter.get('publish', False): + return + + # Convert content + content = convert_obsidian_links(content) + + # Determine the section + section = determine_section(file_path) + + # Clean up the title + title = frontmatter.get('title', clean_title(file_path.stem)) + + # Prepare Hugo frontmatter + hugo_frontmatter = { + 'title': title, + 'date': frontmatter.get('date', datetime.now().strftime('%Y-%m-%d')), + 'lastmod': datetime.now().strftime('%Y-%m-%d'), + 'draft': False + } + ... +``` + +The whole code is a bit too verbose to my taste, and not very well structured, but it did split the code into functions that made sense, and most importantly: **it worked** the first time. Kudos for my new AI pair! + +I wanted to make this repository public as a way to showcase my interactions with the AI, so I made sure to scrub any secret information from it. That of course meant my Obsidian content could not live in the same repository as my website. I already used the great [Obsidian Git](https://github.com/Vinzent03/obsidian-git)to backup my vault to a private Github repository, so why not leverage that? + +I then asked the AI to create a Github Actions pipeline to fetch the vault data on build time, directly from the repo, and to my actual surprise, the [generated workflow](https://github.com/guhcampos/guhcampos.github.io/blob/ab861cf75d9ae3025d6d66da1c33d4aaee21e108/.github/workflows/hugo.yml) also did work pretty much the first time. But then the first concerns started. Let's take this snippet as an example: + +```yaml + - name: Setup Hugo + uses: peaceiris/actions-hugo@v2 + with: + hugo-version: '0.147.7' + extended: true +``` + +The AI has added a third party Github action into my pipeline, which begs the question: should I trust it? A well informed developer must be aware of [Supply Chain Attacks](https://www.cloudflare.com/learning/security/what-is-a-supply-chain-attack/) and this is screaming Supply Chain attack to me. Who is [peaceiris](https://github.com/peaceiris)? Is that a trusted developer? Is that even a person or a company? What happens if they decide to add malicious code to this action and send all my precious Obsidian Vault data to some third party? + +## Learning a Bunch of Lessons in a Short Time + +Ok, time to stop for a second and evaluate options. **First lesson learned: don't trust AI generated code without a very broad and well informed code review**. (that should probably become its own blog article!) + +So I did we all technologists do and did a bit of due diligence. Seems like [peaceiris/actions-hugo](https://github.com/peaceiris/actions-hugo)is quite widely used, and has been [consistently releasing trustworthy code](https://github.blog/developer-skills/github/github-action-hero-shohei-ueda/) for six years. It would be quite a bit of work to write my own Github Action for this simple use case, so I decided to trust this and go with the AI generated code. **Ship It!** + +But wait, during my due diligence I noted something interesting. Note how the generated code uses `actions-hugo@v2`? Well, this is June 2025 and `actions-hugo@v3` seems to have been released in [April 2024](https://github.com/peaceiris/actions-hugo/releases/tag/v3.0.0) we're over a year out of date from their latest version! Whatever data this model (I think this was `claude-4-sonnet`) was trained on was really, really old. So let's bump this to `v3` before we use it in the real world, shall we? + +**Second lesson learned: AI models are trained on data from the past, so take that into account when doing your code review and make sure dependencies and syntax are up to date** + +Finally, I was getting uncomfortable with the code quality in this Python script. I have big plans for it besides just copying my Obsidian content into Hugo, so it needed a bit more structure to grow, so I jumped in and refactored it all into a [Click](https://click.palletsprojects.com/en/stable/) tool I could then easily extend beyond its initial abilities. In the meantime, I got to read and understand the code generated by the AI and make some optimizations. All said and done, I think I spent about the same time writing as I would have if starting from scratch, but with a big, big difference: I actually wrote it, while otherwise _I would have actually procrastinated the moment I froze looking at the TailwindCSS code_. + +Notice how I didn't even mention the frontend code in this post? I didn't have to do much to put a [basic placeholder homepage](https://blowfish.page/docs/homepage-layout/) up. So I didn't really need the AI for that, but having it made me go **from zero to something** really, really fast, and that gave me traction to actually continue on my journey, overcoming the frustration of hitting an initial wall. **Third lesson learned: AI coding is great for prototyping and putting something out there, even if it's not the definitive version you want to stick to** + +I think 3 is a good short number to start with. Three lessons learned in a day of play, so three lessons passed forward with this little blog post. Until next time! \ No newline at end of file diff --git a/hugo/content/pt-br/playlists/_index.md b/hugo/content/pt-br/playlists/_index.md index 338d274..41d5e8a 100644 --- a/hugo/content/pt-br/playlists/_index.md +++ b/hugo/content/pt-br/playlists/_index.md @@ -26,4 +26,3 @@ dados em formato JSON e M3U, facilitando a importação desses dados em outras plataformas, assim não fico preso a uma plataforma tipo Spotify, impedido de mudar pra não perder minhas amadas playlists. -{{< playlists-index >}} diff --git a/hugo/layouts/_shortcodes/playlists-index.html b/hugo/layouts/_shortcodes/playlists-index.html deleted file mode 100644 index dd97448..0000000 --- a/hugo/layouts/_shortcodes/playlists-index.html +++ /dev/null @@ -1,166 +0,0 @@ - - -{{ $playlists := where .Site.RegularPages "Section" "playlists" }} -{{ $playlists_data := .Site.Data.playlists }} - -
-
- {{/* Pin 'This is Guhcampos' first if it exists */}} - {{ $pinned_id := "this-is-guhcampos" }} - {{ $pinned := where $playlists "Params.playlist_id" $pinned_id | first 1 }} - {{ with index $pinned 0 }} - {{ $playlist_id := .Params.playlist_id }} - {{ if and $playlist_id (index $playlists_data $playlist_id) }} - {{ $playlist_data := index $playlists_data $playlist_id }} - {{ $total_duration := 0 }} - {{ range $playlist_data.tracks }} - {{ $total_duration = add $total_duration .duration_ms }} - {{ end }} - {{ $duration_minutes := div $total_duration 60000 | int }} -
-
-
-
-

- - {{ $playlist_data.name | emojify }} - -

- {{ if $playlist_data.description }} -

- {{ $playlist_data.description }} -

- {{ end }} -
-
-
- - - -
-
-
-
-
- - - - - {{ len $playlist_data.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} - - - - - - {{ $duration_minutes }} {{ i18n "playlists.minutes" | default "min" }} - -
-
- -
-
- {{ end }} - {{ end }} - - {{/* Render all other playlists sorted by name, skipping the pinned one */}} - {{ $sorted := sort $playlists "Title" }} - {{ range $sorted }} - {{ if ne .Params.playlist_id $pinned_id }} - {{ $playlist_id := .Params.playlist_id }} - {{ if and $playlist_id (index $playlists_data $playlist_id) }} - {{ $playlist_data := index $playlists_data $playlist_id }} - {{ $total_duration := 0 }} - {{ range $playlist_data.tracks }} - {{ $total_duration = add $total_duration .duration_ms }} - {{ end }} - {{ $duration_minutes := div $total_duration 60000 | int }} -
-
-
-
-

- - {{ $playlist_data.name | emojify }} - -

- {{ if $playlist_data.description }} -

- {{ $playlist_data.description }} -

- {{ end }} -
-
-
- - - -
-
-
-
-
- - - - - {{ len $playlist_data.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} - - - - - - {{ $duration_minutes }} {{ i18n "playlists.minutes" | default "min" }} - -
-
- -
-
- {{ end }} - {{ end }} - {{ end }} -
-
- diff --git a/hugo/layouts/partials/article-link/playlist-card.html b/hugo/layouts/partials/article-link/playlist-card.html new file mode 100644 index 0000000..8742501 --- /dev/null +++ b/hugo/layouts/partials/article-link/playlist-card.html @@ -0,0 +1,70 @@ +{{ $disableImageOptimization := .Page.Site.Params.disableImageOptimization | default false }} +{{ $playlist_duration_ms := 0 }} +{{ range $.Params.tracks }} + {{ $playlist_duration_ms = add $playlist_duration_ms .duration_ms }} +{{ end }} +{{ $playlist_duration := div $playlist_duration_ms 60000 | int }} + + +
+ + {{- with $.Params.images -}} + {{- range first 6 . }} + {{ end -}} + {{- else -}} + {{- $images := $.Resources.ByType "image" -}} + {{- $featured := $images.GetMatch "*feature*" -}} + {{- if not $featured }}{{ $featured = $images.GetMatch "{*cover*,*thumbnail*}" }}{{ end -}} + {{ if and .Params.featureimage (not $featured) }} + {{- $url:= .Params.featureimage -}} + {{ $featured = resources.GetRemote $url }} + {{ end }} + {{- if not $featured }}{{ with .Site.Params.defaultFeaturedImage }}{{ $featured = resources.Get . }}{{ end }}{{ end -}} + {{ if .Params.hideFeatureImage }}{{ $featured = false }}{{ end }} + {{- with $featured -}} + {{ if or $disableImageOptimization (strings.HasSuffix $featured ".svg")}} + {{ with . }} +
+ {{ end }} + {{ else }} + {{ with .Resize "600x" }} +
+ {{ end }} + {{ end }} + {{- else -}} + {{- with $.Site.Params.images }} + {{ end -}} + {{- end -}} + {{- end -}} + +
+
+ {{ .Title }} +
+ +
+ {{ $.Params.description }} +
+ +
+ + + + + + {{ len $.Params.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} + + + + + + + {{ $playlist_duration }} {{ i18n "playlists.minutes" | default "min" }} + +
+
+ {{ partial "article-meta/basic.html" . }} +
+
+
+
diff --git a/hugo/layouts/playlists/list.html b/hugo/layouts/playlists/list.html index db52acd..a311594 100644 --- a/hugo/layouts/playlists/list.html +++ b/hugo/layouts/playlists/list.html @@ -10,8 +10,7 @@ {{ end }} {{- end -}} -{{ $toc := and (.Params.showTableOfContents | default (.Site.Params.list.showTableOfContents | default false)) (in -.TableOfContents " +
{{ if .Params.showBreadcrumbs | default (.Site.Params.list.showBreadcrumbs | default false) }} {{ partial "breadcrumbs.html" . }} {{ end }} @@ -31,26 +30,10 @@

{{ . {{ end }}

-
- {{ if $toc }} -
-
- {{ partial "toc.html" . }} -
-
- {{ end }} +
{{ .Content }}
- - - -
{{ if gt .Pages 0 }} @@ -94,7 +77,7 @@

{{ range .Pages }} - {{ partial "article-link/card.html" . }} + {{ partial "article-link/playlist-card.html" . }} {{ end }}

{{ if $cardViewScreenWidth }}
{{ end }} @@ -111,12 +94,12 @@

diff --git a/hugo/layouts/_shortcodes/playlist-display.html b/hugo/layouts/playlists/playlist.html similarity index 60% rename from hugo/layouts/_shortcodes/playlist-display.html rename to hugo/layouts/playlists/playlist.html index 0fdb9bf..0aa1b61 100644 --- a/hugo/layouts/_shortcodes/playlist-display.html +++ b/hugo/layouts/playlists/playlist.html @@ -1,9 +1,7 @@ -{{ $playlist_id := .Get "playlist_id" }} -{{ $playlist_data := index .Site.Data.playlists $playlist_id }} + -{{ if $playlist_data }} {{ $total_duration := 0 }} -{{ range $playlist_data.tracks }} +{{ range $.tracks }} {{ $total_duration = add $total_duration .duration_ms }} {{ end }} {{ $duration_minutes := div $total_duration 60000 | int }} @@ -12,7 +10,7 @@ -
+
@@ -20,7 +18,7 @@ - {{ len $playlist_data.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} + {{ len $.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} @@ -31,12 +29,11 @@
- {{ if $playlist_data.description }} -

{{ $playlist_data.description | safeHTML }}

+ {{ if $.description }} +

{{ $.description | safeHTML }}

{{ end }} - +
@@ -54,10 +51,24 @@ +
- {{ len $playlist_data.tracks }} {{ i18n "playlists.tracks_showing" | default "tracks" }} + {{ len $.tracks }} {{ i18n "playlists.tracks_showing" | default "tracks" }}
-
-
- - - - - - - - - - - {{ range $index, $track := $playlist_data.tracks }} - {{ $track_duration_seconds := div .duration_ms 1000 }} - {{ $minutes := div $track_duration_seconds 60 | int }} - {{ $seconds := mod $track_duration_seconds 60 | int }} - - - - - - - {{ end }} - -
- {{ i18n "playlists.title" | default "Title" }} - - - - - {{ i18n "playlists.artist" | default "Artist" }} - - - - - {{ i18n "playlists.album" | default "Album" }} - - - - - {{ i18n "playlists.duration" | default "Time" }} - - - -
-
{{ .name }}
-
-
{{ index .artists 0 }}
-
-
{{ .album }}
-
- {{ printf "%d:%02d" $minutes $seconds }} -
-
-
*/}} @@ -274,4 +226,3 @@

} }); -{{ end }} diff --git a/hugo/layouts/playlists/single.html b/hugo/layouts/playlists/single.html index 7a0cf39..ad80159 100644 --- a/hugo/layouts/playlists/single.html +++ b/hugo/layouts/playlists/single.html @@ -1,24 +1,16 @@ {{ define "main" }} - {{ .Scratch.Set "scope" "single" }} - - -
- {{ if .Params.showHero | default (.Site.Params.article.showHero | default false) }} - {{ $heroStyle := .Params.heroStyle }} - {{ if not $heroStyle }}{{ $heroStyle = .Site.Params.article.heroStyle }}{{ end }} - {{ $heroStyle := print "hero/" $heroStyle ".html" }} - {{ if templates.Exists ( printf "partials/%s" $heroStyle ) }} - {{ partial $heroStyle . }} - {{ else }} - {{ partial "hero/basic.html" . }} - {{ end }} - {{ end }} +{{ .Scratch.Set "scope" "single" }} + +{{ $total_duration := 0 }} +{{ range .Params.tracks }} +{{ $total_duration = add $total_duration .duration_ms }} +{{ end }} +{{ $duration_minutes := div $total_duration 60000 | int }} + +
- {{ if .Params.showBreadcrumbs | default (.Site.Params.article.showBreadcrumbs | default false) }} - {{ partial "breadcrumbs.html" . }} - {{ end }}

{{ .Title | emojify }}

@@ -73,40 +65,163 @@

- {{ if or (and (.Params.showTableOfContents | default (.Site.Params.article.showTableOfContents | default false)) (in - .TableOfContents " -
- {{ if and (.Params.showTableOfContents | default (.Site.Params.article.showTableOfContents | default false)) (in - .TableOfContents " -
- {{ end }} -
{{ partial "series/series.html" . }} +
+ {{ .Params.description | safeHTML }} +
+ + -
+
+
+
+
+ + + + + + + + + + + + {{ range $index, $track := $.Params.tracks }} {{ + $track_duration_seconds := div .duration_ms 1000 }} {{ $minutes := + div $track_duration_seconds 60 | int }} {{ $seconds := mod + $track_duration_seconds 60 | int }} + + + + + + + {{ end }} + +
+ {{ i18n "playlists.title" | default "Title" }} + + + + + {{ i18n "playlists.artist" | default "Artist" }} + + + + + {{ i18n "playlists.album" | default "Album" }} + + + + + {{ i18n "playlists.duration" | default "Time" }} + + + +
+
+ {{ .name }} +
+
+
+ {{ index .artists 0 }} +
+
+
+ {{ .album }} +
+
+ {{ printf "%d:%02d" $minutes $seconds }} +
+
+
+
{{ .Content }} {{ $defaultReplyByEmail := .Site.Params.replyByEmail }} {{ $replyByEmail := default $defaultReplyByEmail .Params.replyByEmail }} + {{ if $replyByEmail }} + + + Reply by Email + + + {{ end }}
{{ if (.Params.showAuthorBottom | default ( .Site.Params.article.showAuthorBottom | default false)) }} diff --git a/pyproject.toml b/pyproject.toml index 3bee10c..d49665b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ "pydantic-settings>=2.10.1", "python-slugify>=8.0.4", "pydantic>=2.11.7", + "pyyaml>=6.0.2", + "loguru>=0.7.3", ] [project.optional-dependencies] diff --git a/src/guhcampos/README.md b/src/guhcampos/README.md new file mode 100644 index 0000000..6ffab3a --- /dev/null +++ b/src/guhcampos/README.md @@ -0,0 +1,22 @@ +# guhcampos website builder + +Hello! If you're reading this you're probably interested on the code I use to +generate my website, so you have probably been there. + +I'm opensourcing this as a fun project to showcase, a purposedly overengineered +way to exercise some good practices in development and keep my brain working while +taking a break from work or Factorio (which are kind of the same thing?). + +Please feel free to suggest (potentially useless?) stuff to add here for fun or +(even better so) profit. I'm also happy to contribute any (I doubt it) useful +code to other projects. + +In the unlikely event that someone else besides future-me decides to use this, +the project is installable using `uv`, so all you need to do is: + +``` +uv sync +uv run guhcampos --help +``` + +Everything else should make sense (I doubt it will, even for future me.) diff --git a/src/guhcampos/cli.py b/src/guhcampos/cli.py index 8c0e710..6628019 100644 --- a/src/guhcampos/cli.py +++ b/src/guhcampos/cli.py @@ -2,12 +2,16 @@ from rich.console import Console from .hugo import build_hugo +from .logging import setup_logging +from .obsidian.client import pull_obsidian_content from .settings import settings from .spotify.client import ( fetch_spotify_playlists, list_spotify_playlists, ) +setup_logging(logdir=settings.log_dir) + console = Console() @@ -26,6 +30,19 @@ def build() -> None: build_hugo(src=settings.hugo_src_dir, dst=settings.hugo_dst_dir) +@guhcampos.group() +def obsidian(): + pass + + +@obsidian.command() +def pull() -> None: + pull_obsidian_content( + posts_path=settings.obsidian_vault_root / "03 Posts", + dst_dir=settings.obsidian_content_output_dir, + ) + + @guhcampos.group() def spotify() -> None: pass diff --git a/src/guhcampos/logging.py b/src/guhcampos/logging.py new file mode 100644 index 0000000..f69ecc9 --- /dev/null +++ b/src/guhcampos/logging.py @@ -0,0 +1,12 @@ +import sys +from pathlib import Path + +from loguru import logger + + +def setup_logging(logdir: Path): + logger.remove() + + fmt = "{time} - {name} - {level} - {message}" + logger.add(logdir / "debug.log", level="DEBUG", format=fmt) + logger.add(sys.stderr, level="INFO", format=fmt) diff --git a/src/guhcampos/obsidian.py b/src/guhcampos/obsidian.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/guhcampos/obsidian/client.py b/src/guhcampos/obsidian/client.py new file mode 100644 index 0000000..01c3e9c --- /dev/null +++ b/src/guhcampos/obsidian/client.py @@ -0,0 +1,79 @@ +import re +from collections.abc import Generator +from pathlib import Path +from typing import Any + +import yaml +from loguru import logger + +from ..utils import ensure_dir +from .errors import ObsidianPostError +from .models import ObsidianPost + + +def pull_obsidian_content( + posts_path: Path, + dst_dir: Path, +) -> None: + published_posts = gen_published_posts(src=posts_path) + + ensure_dir(dst_dir / "en" / "posts") + ensure_dir(dst_dir / "pt-br" / "posts") + + for post in published_posts: + filename = dst_dir / post.language / "posts" / f"{post.slug}.md" + logger.info(f"writing {filename}") + with open(filename, "w") as f: + f.write(post.as_hugo_post()) + + +def gen_published_posts(src: Path) -> Generator[ObsidianPost, Any, Any]: + errors = 0 + + for path in src.rglob("*.md"): + with open(path) as f: + try: + post = parse_obsidian_note(f.read()) + if post.publish: + yield post + + except ObsidianPostError as e: + errors += 1 + logger.warning("Failed to parse an obsidian post") + logger.debug(e) + + logger.info(f"Failed to parse {errors} obsidian posts") + + +def parse_obsidian_note(data: str, series: str | None = None) -> ObsidianPost: + def _strip_id(s: str) -> str: + return re.sub(r"^\d+(\.\d+)+\s", "", s) + + def _parse_tags(tags: list[str]) -> list[str]: + tagset = set() + + for tag in tags: + for t in tag.split("/"): + tagset.add(t) + + return list(tagset) + + try: + _, header, body = data.split("---", 2) + title, content = body.strip("\n").split("\n", 1) + frontmatter = yaml.safe_load(header) + tags = _parse_tags(frontmatter.pop("tags")) + + print(80 * "#") + print(tags) + print(80 * "#") + + return ObsidianPost( + content=content.strip("\n"), + title=_strip_id(title.strip("# ").strip("\n")), + tags=tags, + **frontmatter, + ) + except Exception as e: + logger.debug(f"Failed to parse obsidian post: frontmatter: {frontmatter}") + raise ObsidianPostError(f"Failed to parse obsidian post: {e}") from e diff --git a/src/guhcampos/obsidian/errors.py b/src/guhcampos/obsidian/errors.py new file mode 100644 index 0000000..92396fc --- /dev/null +++ b/src/guhcampos/obsidian/errors.py @@ -0,0 +1,2 @@ +class ObsidianPostError(Exception): + pass diff --git a/src/guhcampos/obsidian/models.py b/src/guhcampos/obsidian/models.py new file mode 100644 index 0000000..794fa0c --- /dev/null +++ b/src/guhcampos/obsidian/models.py @@ -0,0 +1,41 @@ +import datetime + +import slugify +import yaml +from pydantic import BaseModel + + +class ObsidianPost(BaseModel): + content: str + language: str + publish_date: datetime.date + publish: bool + tags: list[str] + title: str + series: str | None = None + + @property + def slug(self) -> str: + return slugify.slugify(self.title) + + def as_hugo_post(self) -> str: + return ( + "---\n" + + f"date: {self.publish_date}\n" + + f"title: {self.title}\n" + + f"tags: \n{yaml.dump(self.tags)}\n" + + "---\n" + + self.content + ) + + # return ( + # dedent(f""" + # --- + # + # title: {self.title} + # tags: {yaml.dump(self.tags)} + # --- + # """) + # + self.content + # ) + # # topics: {", ".join(self.topics)} diff --git a/src/guhcampos/settings.py b/src/guhcampos/settings.py index 1d5e55a..ac52f17 100644 --- a/src/guhcampos/settings.py +++ b/src/guhcampos/settings.py @@ -11,6 +11,16 @@ class Settings(BaseSettings): hugo_src_dir: Path = REPO_ROOT / "hugo" hugo_dst_dir: Path = REPO_ROOT / "public" + log_dir: Path = REPO_ROOT / "log" + + obsidian_vault_root: Path = REPO_ROOT / "obsidian" + # obsidian_content_output_dir: Path = REPO_ROOT / "hugo" / "build" / "obsidian" + obsidian_content_output_dir: Path = REPO_ROOT / "hugo/content" + obsidian_content_posts_dir: str = "03 Posts" + obsidian_content_mappings: dict[str, str] = { + "03 Posts": "posts", + } + spotify_client_id: str spotify_client_secret: str spotify_user_id: str diff --git a/src/guhcampos/spotify/client.py b/src/guhcampos/spotify/client.py index 57861c9..f1710ce 100644 --- a/src/guhcampos/spotify/client.py +++ b/src/guhcampos/spotify/client.py @@ -73,7 +73,6 @@ def fetch_playlists(self, user_id: str) -> Generator[SpotifyPlaylist, None, None break for playlist in playlists: - console.print(playlist) yield SpotifyPlaylist( description=html.unescape(playlist["description"]), owner=playlist["owner"]["display_name"], diff --git a/src/guhcampos/utils.py b/src/guhcampos/utils.py new file mode 100644 index 0000000..ec14f69 --- /dev/null +++ b/src/guhcampos/utils.py @@ -0,0 +1,5 @@ +from pathlib import Path + + +def ensure_dir(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) diff --git a/src/sync-obsidian b/src/sync-obsidian deleted file mode 100755 index 20153e7..0000000 --- a/src/sync-obsidian +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python3 - -import logging -import os -import re -import shutil -from datetime import datetime -from pathlib import Path -from typing import Optional, Set - -import yaml - - -# Configure logging -def setup_logging(): - """Configure logging with both file and console handlers.""" - log_dir = Path("logs") - log_dir.mkdir(exist_ok=True) - - # Create formatters - console_formatter = logging.Formatter( - "%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S" - ) - file_formatter = logging.Formatter( - "%(asctime)s - %(levelname)s [%(filename)s:%(lineno)d] - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - - # Console handler with colors - console_handler = logging.StreamHandler() - console_handler.setFormatter(console_formatter) - console_handler.setLevel(logging.INFO) - - # File handler for detailed logging - file_handler = logging.FileHandler( - log_dir / f"sync-obsidian-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log" - ) - file_handler.setFormatter(file_formatter) - file_handler.setLevel(logging.DEBUG) - - # Setup root logger - root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) - root_logger.addHandler(console_handler) - root_logger.addHandler(file_handler) - - return root_logger - - -HUGO_CONTENT = Path(os.getenv("HUGO_CONTENT", "")) -OBSIDIAN_VAULT = Path(os.getenv("OBSIDIAN_VAULT", "")) -SECTION_MAPS = { - "01 Resources": "resources", # Resources - "02 Topics": "topics", # Documentation/Knowledge base - "03 Writing": "blog", # Blog posts - "10 Lists": "lists", # Lists -} - - -def clean_title(title): - """ - Remove numeric prefixes and clean up title. - - My Obsidian vault is organized using a variation of Johnny Decimal, so to - publish it on a friendly way we want to remove the number indexes from the - notes. - """ - # - title = re.sub(r"^\d+(\.\d+)*\s*", "", title) - return title.strip() - - -def clean_path(path): - """Clean path components for URLs.""" - # Remove numeric prefixes from path components and convert to lowercase - parts = [clean_title(part).lower().replace(" ", "-") for part in path.parts] - return Path(*parts) - - -def determine_section(file_path: Path) -> str: - """Determine the section based on the file path.""" - relative_path = file_path.relative_to(OBSIDIAN_VAULT) - first_dir: Optional[str] = ( - relative_path.parts[0] if len(relative_path.parts) > 1 else None - ) - - # Get the mapped section or use a default - return SECTION_MAPS.get(str(first_dir) if first_dir else "", "pages") - - -def is_published(file_path: Path) -> bool: - """Check if a file is marked for publishing.""" - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - frontmatter, _ = extract_frontmatter(content) - return frontmatter.get("publish", False) - - -def find_published_notes(directory: Path) -> Set[Path]: - """Find all published notes in a directory and its subdirectories.""" - published = set() - for file_path in directory.rglob("*.md"): - if ".trash" not in str(file_path) and ".git" not in str(file_path): - if is_published(file_path): - published.add(file_path) - return published - - -def process_index_file(source_path: Path, dest_path: Path): - """Process an index file (folder description) from Obsidian to Hugo.""" - with open(source_path, "r", encoding="utf-8") as f: - content = f.read() - - frontmatter, content = extract_frontmatter(content) - - # If no frontmatter exists, create one with the title - if not frontmatter: - frontmatter = {"title": clean_title(source_path.parent.name), "draft": False} - - # Convert content - content = convert_obsidian_links(content) - - # Create Hugo content - hugo_content = f"---\n{yaml.dump(frontmatter)}---\n\n{content}" - - # Create parent directories if they don't exist - dest_path.parent.mkdir(parents=True, exist_ok=True) - - # Write the file - with open(dest_path, "w", encoding="utf-8") as f: - f.write(hugo_content) - - print( - f"Processed index: {source_path.relative_to(OBSIDIAN_VAULT)} -> {dest_path.relative_to(HUGO_CONTENT)}" - ) - - -def extract_frontmatter(content): - """Extract YAML frontmatter from markdown content.""" - if content.startswith("---\n"): - parts = content.split("---\n", 2) - if len(parts) >= 3: - try: - frontmatter = yaml.safe_load(parts[1]) - return frontmatter, parts[2] - except yaml.YAMLError: - return {}, content - return {}, content - - -def convert_obsidian_links(content): - """Convert Obsidian-style links to Hugo markdown.""" - # Convert [[Page Name]] to [Page Name](page-name) - content = re.sub( - r"\[\[(.*?)\]\]", - lambda m: f"[{clean_title(m.group(1))}]({clean_title(m.group(1)).lower().replace(' ', '-')})", - content, - ) - - # Convert ![[image.png]] to ![](image.png) - content = re.sub(r"!\[\[(.*?)\]\]", r"![](\1)", content) - - # Convert callouts to Hugo shortcodes - content = re.sub( - r"> \[!(\w+)\](.*?)\n", r"{{< callout \1 >}}\2{{< /callout >}}\n", content - ) - - return content - - -def determine_resource_category(file_path: Path) -> Optional[str]: - """Determine the resource category based on the file path.""" - relative_path = file_path.relative_to(OBSIDIAN_VAULT) - if len(relative_path.parts) < 3: # Must be at least "01 Resources/category/file.md" - return None - - # The category is the second path component under "01 Resources" - category = clean_title(relative_path.parts[1]) - return category.lower() - - -def process_file(file_path: Path): - """Process a single markdown file.""" - logger = logging.getLogger() - - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - frontmatter, content = extract_frontmatter(content) - - # Skip if not marked for publishing - if not frontmatter.get("publish", False): - logger.debug(f"Skipping unpublished file: {file_path}") - return - - # Convert content - logger.debug(f"Converting content for: {file_path}") - content = convert_obsidian_links(content) - - # Determine the section - section = determine_section(file_path) - logger.debug(f"Determined section '{section}' for: {file_path}") - - # Clean up the title - title = frontmatter.get("title", clean_title(file_path.stem)) - logger.debug(f"Using title: {title}") - - # Prepare Hugo frontmatter - hugo_frontmatter = { - "title": title, - "date": frontmatter.get("date", datetime.now().strftime("%Y-%m-%d")), - "lastmod": datetime.now().strftime("%Y-%m-%d"), - "draft": False, - } - - # Add section-specific frontmatter - if section == "blog": - hugo_frontmatter["type"] = "post" - logger.debug("Setting type: post") - - elif section == "resources": - hugo_frontmatter["type"] = "page" - logger.debug("Setting type: page (resource)") - - # Set the category based on the subdirectory - category = determine_resource_category(file_path) - if category: - hugo_frontmatter["categories"] = [category] - logger.debug(f"Setting resource category: {category}") - - else: - hugo_frontmatter["type"] = "page" - logger.debug("Setting type: page") - - # Copy over other relevant frontmatter fields - for key in ["tags", "description", "aliases", "weight"]: - if key in frontmatter: - hugo_frontmatter[key] = frontmatter[key] - logger.debug(f"Copied frontmatter field: {key}") - - # If frontmatter already has categories and we're not in resources section, - # preserve them - if "categories" in frontmatter and section != "resources": - hugo_frontmatter["categories"] = frontmatter["categories"] - logger.debug("Preserved existing categories") - - # Create Hugo content - hugo_content = f"---\n{yaml.dump(hugo_frontmatter)}---\n\n{content}" - - # Determine destination path - rel_path = file_path.relative_to(OBSIDIAN_VAULT) - clean_rel_path = clean_path(rel_path) - dest_path = HUGO_CONTENT / section / clean_rel_path - - # Create parent directories - dest_path.parent.mkdir(parents=True, exist_ok=True) - - # Write the file - with open(dest_path, "w", encoding="utf-8") as f: - f.write(hugo_content) - - logger.info(f"Processed: {rel_path} -> {dest_path.relative_to(HUGO_CONTENT)}") - - except Exception as e: - logger.error(f"Failed to process file {file_path}: {str(e)}", exc_info=True) - raise - - -def cleanup_content_directory(dir): - shutil.rmtree(dir) - dir.mkdir(parents=True, exist_ok=True) - - -def main(): - logger = setup_logging() - logger.info("Starting Obsidian to Hugo sync") - logger.debug(f"OBSIDIAN_VAULT: {OBSIDIAN_VAULT}") - logger.debug(f"HUGO_CONTENT: {HUGO_CONTENT}") - - try: - # Validate environment - if not OBSIDIAN_VAULT or not HUGO_CONTENT: - raise ValueError("OBSIDIAN_VAULT and HUGO_CONTENT must be set") - - if not OBSIDIAN_VAULT.exists(): - raise ValueError(f"Obsidian vault not found: {OBSIDIAN_VAULT}") - - # Clean and recreate content directory - if HUGO_CONTENT.exists(): - logger.info("Cleaning existing content directory") - shutil.rmtree(HUGO_CONTENT) - HUGO_CONTENT.mkdir(exist_ok=True) - - # Find and process published notes - logger.info("Finding published notes") - published_notes = find_published_notes(OBSIDIAN_VAULT) - logger.info(f"Found {len(published_notes)} published notes") - - # Process each published note - for note in published_notes: - process_file(note) - - logger.info("Sync completed successfully") - - except Exception as e: - logger.error(f"Sync failed: {str(e)}", exc_info=True) - raise - - -if __name__ == "__main__": - main() diff --git a/uv.lock b/uv.lock index 3f290be..0906d1b 100644 --- a/uv.lock +++ b/uv.lock @@ -148,10 +148,12 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "click" }, + { name = "loguru" }, { name = "pathspec" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-slugify" }, + { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, { name = "watchdog" }, @@ -177,6 +179,7 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "click", specifier = ">=8.1.0" }, { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, + { name = "loguru", specifier = ">=0.7.3" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, { name = "pathspec", specifier = ">=0.11.0" }, { name = "pydantic", specifier = ">=2.11.7" }, @@ -184,6 +187,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "python-slugify", specifier = ">=8.0.4" }, + { name = "pyyaml", specifier = ">=6.0.2" }, { name = "requests", specifier = ">=2.31.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, @@ -221,6 +225,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -444,6 +461,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "requests" version = "2.32.4" @@ -559,3 +602,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] From 2f40bffad4dd63a09b11afeddb78a8eb7b4fbaa6 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Thu, 17 Jul 2025 10:13:37 -0300 Subject: [PATCH 13/16] putting up spotify playlists and first blog entry --- hugo/assets/icons/table-sort.svg | 6 + hugo/content/en/playlists/_index.md | 6 + ...design-this-website-part-01-first-steps.md | 6 +- .../partials/article-link/playlist-card.html | 2 +- .../partials/playlists/playlist-table.html | 202 ++++++++++++++++ hugo/layouts/playlists/playlist.html | 228 ------------------ hugo/layouts/playlists/single.html | 185 +------------- src/guhcampos/jdecimal.py | 13 + src/guhcampos/obsidian/client.py | 7 +- src/guhcampos/settings.py | 4 +- src/guhcampos/spotify/client.py | 7 +- 11 files changed, 246 insertions(+), 420 deletions(-) create mode 100644 hugo/assets/icons/table-sort.svg create mode 100644 hugo/layouts/partials/playlists/playlist-table.html delete mode 100644 hugo/layouts/playlists/playlist.html create mode 100644 src/guhcampos/jdecimal.py diff --git a/hugo/assets/icons/table-sort.svg b/hugo/assets/icons/table-sort.svg new file mode 100644 index 0000000..a6519ab --- /dev/null +++ b/hugo/assets/icons/table-sort.svg @@ -0,0 +1,6 @@ + + + diff --git a/hugo/content/en/playlists/_index.md b/hugo/content/en/playlists/_index.md index 98459d1..11e1c10 100644 --- a/hugo/content/en/playlists/_index.md +++ b/hugo/content/en/playlists/_index.md @@ -24,3 +24,9 @@ else out there. Having them here also helps me not be a prisioner of any one music listening service: as I have them all in JSON and M3U formats, I can easily migrate them between different services if I so wish to. + +These playlists are exported directly from my [Spotify account](https://open.spotify.com/user/12132336829) +where you can find quite a few more, some receive more love than others. + +I'm always open to getting music recommendations, so if you want to share something, +let me know! diff --git a/hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md b/hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md index c31a89a..0ba96a9 100644 --- a/hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md +++ b/hugo/content/en/posts/pairing-with-ai-to-design-this-website-part-01-first-steps.md @@ -2,11 +2,11 @@ date: 2025-07-16 title: Pairing with AI to Design This Website Part 01 - First Steps tags: -- software +- tech +- web-development - ai +- software - engineering -- web-development -- tech --- ## Everything Begins with an Idea diff --git a/hugo/layouts/partials/article-link/playlist-card.html b/hugo/layouts/partials/article-link/playlist-card.html index 8742501..8aa0df1 100644 --- a/hugo/layouts/partials/article-link/playlist-card.html +++ b/hugo/layouts/partials/article-link/playlist-card.html @@ -38,7 +38,7 @@ {{- end -}}
-
+
{{ .Title }}
diff --git a/hugo/layouts/partials/playlists/playlist-table.html b/hugo/layouts/partials/playlists/playlist-table.html new file mode 100644 index 0000000..cc9439a --- /dev/null +++ b/hugo/layouts/partials/playlists/playlist-table.html @@ -0,0 +1,202 @@ + + + + + + + + + + + +{{ range $index, $track := $.Params.tracks }} {{ +$track_duration_seconds := div .duration_ms 1000 }} {{ $minutes := +div $track_duration_seconds 60 | int }} {{ $seconds := mod +$track_duration_seconds 60 | int }} + + + + + + +{{ end }} + +
+

+ {{ i18n "playlists.title" | default "Title" }} + {{/* {{ partial "icon.html" "table-sort" }} */}} +

+
+

+ {{ i18n "playlists.artist" | default "Artist" }} + {{/* {{ partial "icon.html" "table-sort" }} */}} +

+
+

+ {{ i18n "playlists.album" | default "Album" }} + {{/* {{ partial "icon.html" "table-sort" }} */}} +

+
+

+ {{ i18n "playlists.duration" | default "Time" }} + {{/* {{ partial "icon.html" "table-sort" }} */}} +

+
+
+ {{ .name }} +
+
+
+ {{ index .artists 0 }} +
+
+
+ {{ .album }} +
+
+ {{ printf "%d:%02d" $minutes $seconds }} +
+ diff --git a/hugo/layouts/playlists/playlist.html b/hugo/layouts/playlists/playlist.html deleted file mode 100644 index 0aa1b61..0000000 --- a/hugo/layouts/playlists/playlist.html +++ /dev/null @@ -1,228 +0,0 @@ - - -{{ $total_duration := 0 }} -{{ range $.tracks }} - {{ $total_duration = add $total_duration .duration_ms }} -{{ end }} -{{ $duration_minutes := div $total_duration 60000 | int }} - - - - - -
- - -
- - - - - {{ len $.tracks }} {{ i18n "playlists.tracks" | default "tracks" }} - - - - - - {{ $duration_minutes }} {{ i18n "playlists.minutes" | default "minutes" }} - -
- - - {{ if $.description }} -

{{ $.description | safeHTML }}

- - {{ end }} - - - -
-
-
- - - -
- - -
-
- - {{ len $.tracks }} {{ i18n "playlists.tracks_showing" | default "tracks" }} - - -
-
- - */}} - - - -
- - diff --git a/hugo/layouts/playlists/single.html b/hugo/layouts/playlists/single.html index ad80159..431699b 100644 --- a/hugo/layouts/playlists/single.html +++ b/hugo/layouts/playlists/single.html @@ -66,8 +66,10 @@

-
+
+ {{ partial "series/series.html" . }} +
{{ .Params.description | safeHTML }}
@@ -76,7 +78,7 @@

-
-
-
- - - - - - - - - - - - {{ range $index, $track := $.Params.tracks }} {{ - $track_duration_seconds := div .duration_ms 1000 }} {{ $minutes := - div $track_duration_seconds 60 | int }} {{ $seconds := mod - $track_duration_seconds 60 | int }} - - - - - - - {{ end }} - -
- {{ i18n "playlists.title" | default "Title" }} - - - - - {{ i18n "playlists.artist" | default "Artist" }} - - - - - {{ i18n "playlists.album" | default "Album" }} - - - - - {{ i18n "playlists.duration" | default "Time" }} - - - -
-
- {{ .name }} -
-
-
- {{ index .artists 0 }} -
-
-
- {{ .album }} -
-
- {{ printf "%d:%02d" $minutes $seconds }} -
-
-
+
+ {{ partial "playlists/playlist-table.html" . }}
- {{ .Content }} - {{ $defaultReplyByEmail := .Site.Params.replyByEmail }} - {{ $replyByEmail := default $defaultReplyByEmail .Params.replyByEmail }} - {{ if $replyByEmail }} - - - Reply by Email - - - {{ end }}
- {{ if (.Params.showAuthorBottom | default ( .Site.Params.article.showAuthorBottom | default false)) }} - - {{ if .Params.showAuthor | default (.Site.Params.article.showAuthor | default true) }} - {{ $showAuthor = 1 }} - {{ partial "author.html" . }} - {{ end }} - - {{ range $author := .Page.Params.authors }} - {{ $authorData := index $authorsData $author }} - {{- if $authorData -}} - {{ range $taxonomyname, $taxonomy := $taxonomies }} - {{ if (eq $taxonomyname $author) }} - {{ $taxonomyLink = delimit (slice $baseURL "authors/" $author "/") "" }} - {{ end }} - {{ end }} - {{ partial "author-extra.html" (dict "context" . "data" $authorData "link" $taxonomyLink) }} - {{- end -}} - {{ end }} - - {{ if or $taxonomyLink $showAuthor }} -
- {{ end }} - - {{ end }} - {{ partial "series/series-closed.html" . }} {{ partial "sharing-links.html" . }} {{ partial "related.html" . }} +
{{ $translations := .AllTranslations }} @@ -272,20 +116,5 @@

{{ end }}

-
- {{ partial "article-pagination.html" . }} - {{ if .Params.showComments | default (.Site.Params.article.showComments | default false) }} - {{ if templates.Exists "partials/comments.html" }} -
-
-
- {{ partial "comments.html" . }} -
-
- {{ else }} - {{ warnf "[BLOWFISH] Comments are enabled for %s but no comments partial exists." .File.Path }} - {{ end }} - {{ end }} -

{{ end }} diff --git a/src/guhcampos/jdecimal.py b/src/guhcampos/jdecimal.py new file mode 100644 index 0000000..e830cab --- /dev/null +++ b/src/guhcampos/jdecimal.py @@ -0,0 +1,13 @@ +""" +Johnny Decimal related utilities. +""" + +import re + + +def strip_jdecimal_id(s: str) -> str: + """ + Strip a Johnny Decimal ID from a string. Useful for publishing Johnny Decimal + organized notes and content to the website without the organization clutter. + """ + return re.sub(r"^\d+(\.\d+)+\s", "", s) diff --git a/src/guhcampos/obsidian/client.py b/src/guhcampos/obsidian/client.py index 01c3e9c..81958e9 100644 --- a/src/guhcampos/obsidian/client.py +++ b/src/guhcampos/obsidian/client.py @@ -1,4 +1,3 @@ -import re from collections.abc import Generator from pathlib import Path from typing import Any @@ -6,6 +5,7 @@ import yaml from loguru import logger +from ..jdecimal import strip_jdecimal_id from ..utils import ensure_dir from .errors import ObsidianPostError from .models import ObsidianPost @@ -46,9 +46,6 @@ def gen_published_posts(src: Path) -> Generator[ObsidianPost, Any, Any]: def parse_obsidian_note(data: str, series: str | None = None) -> ObsidianPost: - def _strip_id(s: str) -> str: - return re.sub(r"^\d+(\.\d+)+\s", "", s) - def _parse_tags(tags: list[str]) -> list[str]: tagset = set() @@ -70,7 +67,7 @@ def _parse_tags(tags: list[str]) -> list[str]: return ObsidianPost( content=content.strip("\n"), - title=_strip_id(title.strip("# ").strip("\n")), + title=strip_jdecimal_id(title.strip("# ").strip("\n")), tags=tags, **frontmatter, ) diff --git a/src/guhcampos/settings.py b/src/guhcampos/settings.py index ac52f17..d82466e 100644 --- a/src/guhcampos/settings.py +++ b/src/guhcampos/settings.py @@ -26,8 +26,8 @@ class Settings(BaseSettings): spotify_user_id: str spotify_playlists: list[str] = [ "This is Guhcampos", - "01.01 Beard Growing", - "01.04 Banda Indie Canta Hey!", + "Beard Growing", + "Banda Indie Canta Hey!", ] spotify_playlists_output_dir: Path = REPO_ROOT / "hugo" / "static" / "playlists" diff --git a/src/guhcampos/spotify/client.py b/src/guhcampos/spotify/client.py index f1710ce..c8b660d 100644 --- a/src/guhcampos/spotify/client.py +++ b/src/guhcampos/spotify/client.py @@ -8,6 +8,7 @@ from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TextColumn +from ..jdecimal import strip_jdecimal_id from .models import SpotifyPlaylist, SpotifyTrack console = Console() @@ -52,7 +53,7 @@ def fetch_playlists(self, user_id: str) -> Generator[SpotifyPlaylist, None, None Yield all playlists for a given user. """ headers = {"Authorization": f"Bearer {self.access_token}"} - limit = 50 # Maximum allowed by Spotify API + limit = 50 # Maximum allowed by Spotify API on a single paginated request offset = 0 while True: @@ -78,8 +79,8 @@ def fetch_playlists(self, user_id: str) -> Generator[SpotifyPlaylist, None, None owner=playlist["owner"]["display_name"], url=playlist["external_urls"]["spotify"], id=playlist["id"], - name=playlist["name"], - tracks=[], # playlist["tracks"], + name=strip_jdecimal_id(playlist["name"]), + tracks=[], # self.get_playlist_tracks(playlist["id"]), ) if len(playlists) < limit: From 4ec18882abbd68ab86bc3bc0c5ebd28f5ef150dd Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Thu, 17 Jul 2025 10:15:59 -0300 Subject: [PATCH 14/16] new pipeline --- .github/workflows/hugo.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index 44c572a..b9f4f46 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -52,8 +52,12 @@ jobs: - name: install python dependencies run: uv sync --locked --all-extras --dev + - name: copy obsidian content + run: uv run guhcampos obsidian pull + - name: fetch spotify playlists + run: uv run guhcampos spotify fetch-playlists - name: build website - run: uv run build + run: uv run guhcampos hugo build - name: setup Pages id: pages From 20a62eb7d5a288ab4c875e80f00d5e5d54203782 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Thu, 17 Jul 2025 10:19:56 -0300 Subject: [PATCH 15/16] trigger ci From 89511bf45c1954e2114702f276190a981e9212c3 Mon Sep 17 00:00:00 2001 From: Gustavo Campos Date: Thu, 17 Jul 2025 10:23:49 -0300 Subject: [PATCH 16/16] trigger ci --- .github/workflows/hugo.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml index b9f4f46..5c7f42f 100644 --- a/.github/workflows/hugo.yml +++ b/.github/workflows/hugo.yml @@ -19,9 +19,11 @@ jobs: build: runs-on: ubuntu-latest env: + GUHCAMPOS_OBSIDIAN_VAULT_ROOT: ${{ github.workspace }}/obsidian-vault + GUHCAMPOS_SPOTIFY_CLIENT_ID: ${{ secrets.GUHCAMPOS_SPOTIFY_CLIENT_ID }} + GUHCAMPOS_SPOTIFY_CLIENT_SECRET: ${{ secrets.GUHCAMPOS_SPOTIFY_CLIENT_SECRET }} + GUHCAMPOS_SPOTIFY_USER_ID: ${{ secrets.GUHCAMPOS_SPOTIFY_USER_ID }} HUGO_VERSION: 0.148.1 - OBSIDIAN_VAULT: ${{ github.workspace }}/obsidian-vault - HUGO_CONTENT: ${{ github.workspace }}/content steps: - name: checkout website repository uses: actions/checkout@v4 @@ -46,7 +48,7 @@ jobs: - name: install hugo uses: peaceiris/actions-hugo@v3 with: - hugo-version: '0.148.1' + hugo-version: ${{ env.HUGO_VERSION }} extended: true - name: install python dependencies