├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── renovate.json └── workflows │ ├── book.yml │ ├── ci.yml │ ├── pre-commit.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── actions ├── age │ └── action.yml ├── book │ └── action.yml ├── build │ └── action.yml ├── coverage │ └── action.yml ├── cradle │ └── action.yml ├── deptry │ └── action.yml ├── docker │ └── action.yml ├── environment │ └── action.yml ├── flow │ └── action.yml ├── jupyter │ └── action.yml ├── latex │ └── action.yml ├── marimo │ └── action.yml ├── pdoc │ └── action.yml ├── pre-commit │ └── action.yml ├── tag │ └── action.yml └── test │ └── action.yml ├── book ├── _config.yml ├── _toc.yml ├── docs │ ├── api.md │ ├── index.md │ └── reports.md └── marimo │ └── demo.py ├── demo.png ├── docker ├── Dockerfile ├── push.sh └── requirements.txt ├── pyproject.toml ├── src ├── cradle │ ├── __init__.py │ ├── cli.py │ ├── templates.yaml │ └── utils │ │ ├── __init__.py │ │ ├── gh_client.py │ │ ├── git.py │ │ └── questions.py └── tests │ ├── conftest.py │ ├── resources │ ├── .copier-answers.yml │ └── broken.yml │ ├── test_cli.py │ ├── test_git.py │ ├── test_git_more.py │ └── test_questions.py └── uv.lock /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # cradle Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting any member of the project team. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 71 | version 1.4, available at 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to cradle 2 | 3 | This document is a guide to contributing to cradle 4 | 5 | We welcome all contributions. You don't need to be an expert (in optimization) 6 | to help out. 7 | 8 | ## Checklist 9 | 10 | Contributions are made through 11 | [pull requests](https://help.github.com/articles/using-pull-requests/). 12 | Before sending a pull request, make sure you do the following: 13 | 14 | - Run 'make fmt' to make sure your code adheres to our [coding style](#code-style). 15 | This step also includes our license on top of your new files. 16 | - [Write unit tests](#writing-unit-tests) 17 | - Run the [unit tests](#running-unit-tests) and check that they're passing 18 | 19 | ## Building cradle from source 20 | 21 | You'll need to build cradle locally in order to start editing code. 22 | To install cradle from source, clone the Github 23 | repository, navigate to its root, and run the following command: 24 | 25 | ```bash 26 | make install 27 | ``` 28 | 29 | We assume you have [poetry](https://python-poetry.org) installed. 30 | 31 | ## Contributing code 32 | 33 | To contribute to cradle, send us pull requests. 34 | For those new to contributing, check out Github's 35 | [guide](https://help.github.com/articles/using-pull-requests/). 36 | 37 | Once you've made your pull request, a member of the cradle 38 | development team will assign themselves to review it. You might have a few 39 | back-and-forths with your reviewer before it is accepted, which is completely normal. 40 | Your pull request will trigger continuous integration tests for many different 41 | Python versions and different platforms. If these tests start failing, please 42 | fix your code and send another commit, which will re-trigger the tests. 43 | 44 | If you'd like to add a new feature to cradle, please do propose your 45 | change on a Github issue, to make sure that your priorities align with ours. 46 | 47 | If you'd like to contribute code but don't know where to start, try one of the 48 | following: 49 | 50 | - Read the cradle source and enhance the documentation, 51 | or address TODOs 52 | - Browse the [issue tracker](https://github.com/cvxgrp/cradle/issues), 53 | and look for the issues tagged "help wanted". 54 | 55 | ## License 56 | 57 | A license is added to new files automatically as a pre-commit hook. 58 | 59 | ## Code style 60 | 61 | We use black and ruff to enforce our Python coding style. 62 | Before sending us a pull request, navigate to the project root 63 | and run 64 | 65 | ```bash 66 | make fmt 67 | ``` 68 | 69 | to make sure that your changes abide by our style conventions. Please fix any 70 | errors that are reported before sending the pull request. 71 | 72 | ## Writing unit tests 73 | 74 | Most code changes will require new unit tests. Even bug fixes require unit tests, 75 | since the presence of bugs usually indicates insufficient tests. 76 | cradle tests live in the directory `tests`, 77 | which contains many files, each of which contains many unit tests. 78 | When adding tests, try to find a file in which your tests should belong; 79 | if you're testing a new feature, you might want to create a new test file. 80 | 81 | We use the popular Python [pytest](https://docs.pytest.org/en/) framework for our 82 | tests. 83 | 84 | ## Running unit tests 85 | 86 | We use `pytest` to run our unit tests. 87 | To run all unit tests run the following command: 88 | 89 | ```bash 90 | make test 91 | ``` 92 | 93 | Please make sure that your change doesn't cause any of the unit tests to fail. 94 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:recommended", 4 | ":enablePreCommit", 5 | ":automergeMinor", 6 | ":dependencyDashboard", 7 | ":maintainLockFilesWeekly", 8 | ":semanticCommits", 9 | ":pinDevDependencies" 10 | ], 11 | "enabledManagers": [ 12 | "pep621", 13 | "pre-commit", 14 | "github-actions" 15 | ], 16 | "timezone": "Asia/Dubai", 17 | "schedule": [ 18 | "before 10am on tuesday" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/book.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow for building documentation and running various checks 2 | # This workflow builds documentation components and runs tests for the project 3 | 4 | name: "book" 5 | 6 | # Trigger the workflow on push events 7 | # This ensures the documentation is automatically updated whenever code changes are pushed to main 8 | on: 9 | push: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read # Read-only access to repository contents 15 | 16 | jobs: 17 | # Job to generate marimo interactive notebooks 18 | marimo: 19 | runs-on: "ubuntu-latest" 20 | steps: 21 | # Step 1: Checkout the repository code 22 | - name: Checkout ${{ github.repository }} 23 | uses: actions/checkout@v4 24 | 25 | # Step 2: Build the virtual environment 26 | - name: "Build the virtual environment for ${{ github.repository }}" 27 | uses: ./actions/environment 28 | 29 | # Step 3: Run marimo action to process interactive notebooks 30 | - uses: ./actions/marimo 31 | with: 32 | command: 'export html-wasm "$file" -o "artifacts/marimo/${filename}/index.html"' 33 | 34 | 35 | # Job to generate API documentation using pdoc 36 | pdoc: 37 | runs-on: "ubuntu-latest" 38 | steps: 39 | # Step 1: Checkout the repository code 40 | - name: Checkout ${{ github.repository }} 41 | uses: actions/checkout@v4 42 | 43 | # Step 2: Build the virtual environment 44 | - name: "Build the virtual environment for ${{ github.repository }}" 45 | uses: ./actions/environment 46 | 47 | # Step 3: Generate API documentation with pdoc 48 | - uses: ./actions/pdoc 49 | with: 50 | source-folder: 'src/cradle' # Path to the source code to document 51 | 52 | # Job to analyze code age 53 | age: 54 | runs-on: "ubuntu-latest" 55 | steps: 56 | # Step 1: Checkout the repository code 57 | - name: Checkout ${{ github.repository }} 58 | uses: actions/checkout@v4 59 | 60 | # Step 2: Build the virtual environment 61 | - name: "Build the virtual environment for ${{ github.repository }}" 62 | uses: ./actions/environment 63 | 64 | # Step 3: Run code age analysis 65 | - uses: ./actions/age 66 | 67 | # Job to run tests with coverage reporting 68 | test: 69 | runs-on: "ubuntu-latest" 70 | steps: 71 | # Step 1: Checkout the repository code 72 | - name: Checkout ${{ github.repository }} 73 | uses: actions/checkout@v4 74 | 75 | # Step 2: Build the virtual environment 76 | - name: "Build the virtual environment for ${{ github.repository }}" 77 | uses: ./actions/environment 78 | 79 | # Step 3: Run tests with coverage reporting 80 | - uses: ./actions/coverage 81 | with: 82 | source-folder: 'src/cradle' # Path to the source code 83 | tests-folder: 'src/tests' # Path to the tests 84 | 85 | # Job to process Jupyter notebooks 86 | jupyter: 87 | runs-on: "ubuntu-latest" 88 | steps: 89 | # Step 1: Checkout the repository code 90 | - name: Checkout ${{ github.repository }} 91 | uses: actions/checkout@v4 92 | 93 | # Step 2: Build the virtual environment 94 | - name: "Build the virtual environment for ${{ github.repository }}" 95 | uses: ./actions/environment 96 | 97 | # Step 3: Process Jupyter notebooks 98 | - uses: ./actions/jupyter 99 | 100 | # Job to build the final documentation book 101 | book: 102 | runs-on: "ubuntu-latest" 103 | needs: [test, pdoc, jupyter, marimo, age] # This job depends on all previous jobs 104 | 105 | permissions: 106 | id-token: write 107 | pages: write 108 | 109 | environment: 110 | name: github-pages 111 | 112 | steps: 113 | # Step 1: Checkout the repository code 114 | - name: Checkout [${{ github.repository }}] 115 | uses: actions/checkout@v4 116 | 117 | # Step 2: Build and publish the documentation book 118 | - uses: ./actions/book 119 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow for continuous integration 2 | # This workflow runs tests on multiple operating systems and Python versions 3 | 4 | name: "CI" 5 | 6 | on: 7 | push: # Trigger on push events 8 | 9 | permissions: 10 | contents: read # Read-only access to repository contents 11 | 12 | jobs: 13 | test: 14 | # The type of runner that the job will run on 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | # Test on multiple operating systems and Python versions 20 | os: [ ubuntu-latest, macos-latest ] 21 | python-version: [ '3.10', '3.11', '3.12', '3.13' ] 22 | 23 | 24 | # Steps represent a sequence of tasks that will be executed as part of the job 25 | steps: 26 | # Step 1: Checkout the repository code 27 | - name: Checkout ${{ github.repository }} 28 | uses: actions/checkout@v4 29 | 30 | # Step 2: Build the virtual environment with the specified Python version 31 | - name: "Build the virtual environment for ${{ github.repository }}" 32 | uses: ./actions/environment 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | # Step 3: Run the tests 37 | - uses: ./actions/test 38 | with: 39 | tests-folder: src/tests # Path to the tests directory 40 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow for code quality checks 2 | # This workflow runs various checks including tests, dependency analysis, pre-commit hooks, and dependency age analysis 3 | name: pre-commit 4 | 5 | on: 6 | push: # Trigger on push events 7 | 8 | permissions: 9 | checks: write # Permission to write check results 10 | contents: read # Read-only access to repository contents 11 | 12 | jobs: 13 | # Job to check for dependency issues 14 | deptry: 15 | runs-on: ubuntu-latest 16 | steps: 17 | # Step 1: Checkout the repository code 18 | - name: Checkout ${{ github.repository }} 19 | uses: actions/checkout@v4 20 | 21 | # Step 2: Build the virtual environment 22 | - name: "Build the virtual environment for ${{ github.repository }}" 23 | uses: ./actions/environment 24 | 25 | # Step 3: Run deptry to check for dependency issues 26 | - uses: ./actions/deptry 27 | with: 28 | source-folder: src/cradle # Path to the source code 29 | 30 | # Job to run pre-commit hooks 31 | pre-commit: 32 | runs-on: ubuntu-latest 33 | steps: 34 | # Step 1: Checkout the repository code 35 | - name: Checkout ${{ github.repository }} 36 | uses: actions/checkout@v4 37 | 38 | # Step 2: Run pre-commit hooks 39 | - name: pre-commit 40 | uses: ./actions/pre-commit 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow for version bumping and package publishing 2 | # This workflow creates a new version tag, builds the package, and publishes it to PyPI 3 | 4 | name: Bump version and publish 5 | 6 | on: 7 | workflow_dispatch # Manual trigger only 8 | 9 | jobs: 10 | # Job to create a new version tag and build the package 11 | tag: 12 | permissions: 13 | contents: write # Permission to write to repository contents (for creating tags) 14 | 15 | runs-on: ubuntu-latest 16 | outputs: 17 | new_tag: ${{ steps.tag_version.outputs.new_tag }} 18 | steps: 19 | # Step 1: Checkout the repository code 20 | - name: Checkout ${{ github.repository }} 21 | uses: actions/checkout@v4 22 | 23 | # Step 2: Generate a new version tag 24 | - name: Generate Tag 25 | id: tag_version # ID to reference this step's outputs later 26 | uses: ./actions/tag 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} # Token for authentication 29 | 30 | # Step 3: Build the package with the new version tag 31 | - name: Build package 32 | uses: ./actions/build 33 | with: 34 | tag: ${{ steps.tag_version.outputs.new_tag }} # Use the tag created in step 2 35 | 36 | - name: Debug Output Tag 37 | run: | 38 | echo "Tag: ${{ steps.tag_version.outputs.new_tag }}" 39 | 40 | # Job to publish the built package to PyPI 41 | publish: 42 | needs: tag # This job depends on the tag job 43 | runs-on: ubuntu-latest 44 | environment: release # Use the release environment 45 | 46 | permissions: 47 | contents: read # Read-only access to repository contents 48 | # This permission is required for trusted publishing to PyPI 49 | id-token: write # Permission to write ID tokens for PyPI authentication 50 | 51 | steps: 52 | # Step 1: Download the built package artifact 53 | - uses: actions/download-artifact@v4 54 | with: 55 | name: dist # Name of the artifact 56 | path: dist # Path to download the artifact to 57 | 58 | # Step 2: Publish the package to PyPI 59 | - name: Publish to PyPI 60 | uses: pypa/gh-action-pypi-publish@release/v1 # Official PyPI publishing action 61 | 62 | docker: 63 | needs: tag 64 | permissions: 65 | contents: read 66 | packages: write 67 | 68 | runs-on: ubuntu-latest 69 | steps: 70 | # ----------------------------------------------------------------------------- 71 | # Step 1: Checkout the repository 72 | # ----------------------------------------------------------------------------- 73 | - name: Checkout [${{ github.repository }}] 74 | uses: actions/checkout@v4 75 | 76 | - name: Build and Push Docker Image 77 | uses: tschm/cradle/actions/docker@v0.1.71 78 | with: 79 | github_repository: ${{ github.repository }} 80 | tag: ${{ needs.tag.outputs.new_tag }} 81 | github_token: ${{ secrets.GITHUB_TOKEN }} 82 | github_actor: ${{ github.actor }} 83 | dockerfiles: 'docker/Dockerfile' 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .task 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | .pytest_cache 9 | .ruff_cache 10 | 11 | htmlcov 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | #htmlcov/ 47 | #.tox/ 48 | #.nox/ 49 | .coverage 50 | #.coverage.* 51 | #.cache 52 | #nosetests.xml 53 | #coverage.xml 54 | # *.cover 55 | # *.py,cover 56 | # .hypothesis/ 57 | # .pytest_cache/ 58 | # cover/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | .pybuilder/ 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | # For a library or package, you might want to ignore these files since the code is 93 | # intended to run in multiple environments; otherwise, check them in: 94 | # .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/#use-with-ide 116 | .pdm.toml 117 | 118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 119 | __pypackages__/ 120 | 121 | # Celery stuff 122 | celerybeat-schedule 123 | celerybeat.pid 124 | 125 | # SageMath parsed files 126 | *.sage.py 127 | 128 | # Environments 129 | .venv 130 | venv/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-toml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: v0.11.13 11 | hooks: 12 | - id: ruff 13 | args: [ --fix, --exit-non-zero-on-fix, --unsafe-fixes ] 14 | # Run the formatter 15 | - id: ruff-format 16 | 17 | - repo: https://github.com/igorshubovych/markdownlint-cli 18 | rev: v0.45.0 19 | hooks: 20 | - id: markdownlint 21 | 22 | - repo: https://github.com/asottile/pyupgrade 23 | rev: v3.20.0 24 | hooks: 25 | - id: pyupgrade 26 | 27 | - repo: https://github.com/python-jsonschema/check-jsonschema 28 | rev: 0.33.0 29 | hooks: 30 | - id: check-renovate 31 | args: ["--verbose"] 32 | - id: check-github-workflows 33 | args: ["--verbose"] 34 | 35 | - repo: https://github.com/rhysd/actionlint 36 | rev: v1.7.7 37 | hooks: 38 | - id: actionlint 39 | args: [-ignore, SC] 40 | 41 | - repo: https://github.com/crate-ci/typos 42 | rev: v1.33.1 43 | hooks: 44 | - id: typos 45 | exclude: \.gitignore 46 | 47 | - repo: https://github.com/abravalheri/validate-pyproject 48 | rev: v0.24.1 49 | hooks: 50 | - id: validate-pyproject 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Thomas Schmelzer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Colors for pretty output 2 | BLUE := \033[36m 3 | BOLD := \033[1m 4 | RESET := \033[0m 5 | 6 | .DEFAULT_GOAL := help 7 | 8 | .PHONY: help verify install fmt test clean 9 | 10 | ##@ Development Setup 11 | 12 | venv: 13 | @printf "$(BLUE)Creating virtual environment...$(RESET)\n" 14 | @curl -LsSf https://astral.sh/uv/install.sh | sh 15 | @uv venv --python 3.12 16 | 17 | verify: ## Verify existence of ssh connection and gh 18 | @printf "$(BLUE)Verify existence of ssh and gh...$(RESET)\n" 19 | ssh -T git@github.com || true 20 | gh --version 21 | 22 | install: venv ## Install all dependencies using uv 23 | @printf "$(BLUE)Installing dependencies...$(RESET)\n" 24 | @uv sync --all-extras 25 | 26 | ##@ Code Quality 27 | 28 | fmt: venv ## Run code formatting and linting 29 | @printf "$(BLUE)Running formatters and linters...$(RESET)\n" 30 | @uv pip install pre-commit 31 | @uv run pre-commit install 32 | @uv run pre-commit run --all-files 33 | 34 | ##@ Testing 35 | 36 | test: install ## Run all tests 37 | @printf "$(BLUE)Running tests...$(RESET)\n" 38 | @uv run pytest src/tests 39 | 40 | ##@ Cleanup 41 | 42 | clean: ## Clean generated files and directories 43 | @printf "$(BLUE)Cleaning project...$(RESET)\n" 44 | @git clean -d -X -f 45 | 46 | ##@ Help 47 | 48 | help: ## Display this help message 49 | @printf "$(BOLD)Usage:$(RESET)\n" 50 | @printf " make $(BLUE)$(RESET)\n\n" 51 | @printf "$(BOLD)Targets:$(RESET)\n" 52 | @awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " $(BLUE)%-15s$(RESET) %s\n", $$1, $$2 } /^##@/ { printf "\n$(BOLD)%s$(RESET)\n", substr($$0, 5) }' $(MAKEFILE_LIST) 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 qCradle 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) 4 | [![PyPI version](https://badge.fury.io/py/qCradle.svg)](https://badge.fury.io/py/qCradle) 5 | [![Coverage Status](https://coveralls.io/repos/github/tschm/cradle/badge.png?branch=main)](https://coveralls.io/github/tschm/cradle?branch=main) 6 | [![ci](https://github.com/tschm/cradle/actions/workflows/ci.yml/badge.svg)](https://github.com/tschm/cradle/actions/workflows/ci.yml) 7 | [![CodeFactor](https://www.codefactor.io/repository/github/tschm/cradle/badge)](https://www.codefactor.io/repository/github/tschm/cradle) 8 | [![Renovate enabled](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://github.com/renovatebot/renovate) 9 | 10 | 🛠️ qcradle is a command line tool to create repos based on a group of templates. 11 | It has been created 12 | to accelerate, simplify and harmonize the development 13 | of experiments and quantitative strategies. 14 | 15 | Assuming the presence of gh, uvx and a valid ssh-connection 16 | with GitHub you can start the tool with 17 | 18 | ```bash 19 | uvx qcradle 20 | ``` 21 | 22 | ![Creating a repository from the command line](https://raw.githubusercontent.com/tschm/cradle/main/demo.png) 23 | 24 | **qcradle** is a tool inspired by [Cookiecutter](https://cookiecutter.readthedocs.io/en/stable/#), 25 | but more biased towards quants, researchers, and academics. 26 | 27 | Whether you're building entire Python packages or financial models, 28 | running simulations, or writing academic papers, 29 | qcradle helps you hit the ground running with a structured 30 | and efficient setup following the most recent standards set in 2025. 31 | 32 | We use [uv](https://github.com/astral-sh/uv), [hatch](https://hatch.pypa.io/), 33 | [marimo](https://marimo.io/) and [Tectonic](https://tectonic-typesetting.github.io/). 34 | Supporting [DevContainers](https://containers.dev/), 35 | [Renovate](https://github.com/renovatebot/renovate), 36 | and [Dependabot](https://github.com/dependabot), 37 | we take full advantage of [GitHub Workflows](https://docs.github.com/en/actions/using-workflows/about-workflows). 38 | 39 | Each template comes with curated [pre-commit hooks](https://pre-commit.com/). 40 | We compile [Jupyter Books](https://jupyterbook.org/) to collect 41 | test reports, API documentation, and notebooks. 42 | 43 | Let’s make project setup as rigorous as your research! 44 | 45 | ## 📚 Examples 46 | 47 | Users can interact with qcradle by either creating templates or 48 | by using existing templates to create projects. We would be 49 | delighted to list your public work here: 50 | 51 | ### 🏆 User projects 52 | 53 | We would like to encourage our users to point to public repositories 54 | created with the qcradle. We start with 55 | 56 | * [cvxball](https://github.com/cvxgrp/cvxball). We created badges 57 | for you 58 | 59 | ### 🧩 User templates 60 | 61 | Please share your templates with the world! 62 | 63 | ## 🔧 Install gh 64 | 65 | Please install GitHub's official command line tool [gh](https://github.com/cli/cli). 66 | This tool is used to create GitHub repos from the command line. 67 | 68 | Verify the existence of the tool and a valid SSH connection with 69 | 70 | ```bash 71 | ssh -T git@github.com 72 | gh --version 73 | ``` 74 | 75 | [Documentation](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent) 76 | to establish a new ssh keypair. 77 | 78 | ## 🔄 Install uv and uvx 79 | 80 | 🚀 uv is a modern, high-performance Python package manager and installer 81 | written in Rust. 82 | It serves as a drop-in replacement for traditional tools like pip and pipx. 83 | For macOS and Linux: 84 | 85 | ```bash 86 | curl -LsSf https://astral.sh/uv/install.sh | sh 87 | ``` 88 | 89 | For Windows follow the [official instructions](https://docs.astral.sh/uv/getting-started/installation/) 90 | 91 | ## 🧠 Understanding uvx 92 | 93 | 🔍 uvx is a command provided by uv to run tools published as Python packages 94 | without installing them permanently. It creates temporary, 95 | isolated environments for these tools: 96 | 97 | ```bash 98 | uvx qcradle 99 | ``` 100 | 101 | This command will: 102 | 103 | * Resolve and install the qcradle package in a temporary environment. 104 | * Execute the qcradle command. 105 | 106 | **Note**: If you plan to use a tool frequently, consider installing 107 | it permanently using uv: 108 | 109 | ```bash 110 | uv tool install qcradle 111 | ```` 112 | 113 | Once the tool is permanently installed it is enough to start it with 114 | 115 | ```bash 116 | qcradle 117 | ``` 118 | 119 | ## 📝 Templates 120 | 121 | ✨ You could create your own templates and standardize project structures 122 | across your team or organization. 123 | It's essentially a project scaffolding tool that helps maintain consistency 124 | in Python projects. 125 | 126 | We currently offer $4$ standard templates out of the box 127 | 128 | * 📄 The document template 129 | * 🧪 The experiments template 130 | * 📦 The package template 131 | * 📊 The R template 132 | 133 | ### 🌟 Standard Templates 134 | 135 | We follow the one template, one repository policy. 136 | You are encouraged to create your own templates and we give $4$ examples that 137 | may serve as inspiration 138 | 139 | #### 📄 [The document template](https://github.com/tschm/paper) 140 | 141 | The template supports the fast creation of repositories of LaTeX documents. 142 | The repo can compile your LaTeX documents with every commit and put them 143 | on a dedicated branch. 144 | 145 | #### 🧪 [The experiments template](https://github.com/tschm/experiments) 146 | 147 | Here we support the creation of notebooks without the ambition to release software. 148 | The repo is not minimalistic but comes with a curated set of pre-commit hooks and 149 | follows modern and established guidelines. The notebooks are based on Marimo. 150 | 151 | #### 📦 [The package template](https://github.com/tschm/package) 152 | 153 | The package template is most useful when the final 154 | goal is the release of software to a registry, e.g. pypi. 155 | It offers full uv support and compiles documentation 156 | into a Jupyter Book. 157 | 158 | #### 📊 [The R template](https://github.com/tschm/cradle_r) 159 | 160 | Here we expose R Studio in a devcontainer. 161 | 162 | ### 🔒 Proprietary templates 163 | 164 | #### 🛠️ Creation 165 | 166 | You can create your very own templates and we recommend to start with 167 | forking the 168 | [dedicated repo](https://github.com/tschm/template/blob/main/README.md) 169 | for the job. 170 | 171 | Templates rely on [Jinja](https://jinja.palletsprojects.com/en/stable/). 172 | At the root level the repo needs a 'copier.yml' file and a 'template' folder. 173 | 174 | Each template is tested using [act](https://github.com/nektos/act), e.g. 175 | we render the project template and test the workflows of the created project. 176 | This helps to avoid creating projects starting their life in a broken state. 177 | 178 | #### 🚀 Usage 179 | 180 | We essentially expose the copier interface directly with 181 | minor modifications, e.g. if the user is not submitting a source template 182 | we offer to choose one of the standard templates. 183 | 184 | Any cradle template could be used directly as the first 'template' 185 | argument 186 | 187 | ```bash 188 | uvx qcradle --template=git@github.com:tschm/paper.git 189 | ``` 190 | 191 | By default, Copier (and hence the repo-launcher) will copy from the last 192 | release found in template Git tags, sorted as 193 | [PEP 440](https://peps.python.org/pep-0440/). 194 | 195 | ### 🔄 Update existing projects 196 | 197 | Templates are moving targets in most professional setups. It is possible to update 198 | projects created with the help of the qcradle by specifying an existing path 199 | instead of a template. 200 | 201 | ```bash 202 | uvx qcradle --dst_path=/Users/thomasschmelzer/projects/my_marimo_experiments 203 | ``` 204 | 205 | The tool expects a full path. Your repo should contain your previous answers 206 | in a file '.copier-answers.yml' which serve as default arguments for the 207 | questions you have been asked before. All standard templates create the file. 208 | 209 | ## 🔄 GitHub Actions 210 | 211 | ⚙️ This repository provides a collection of reusable 212 | GitHub Actions that can be used by other repositories. 213 | These actions are defined in the `actions` directory and 214 | can be referenced in your workflows. 215 | 216 | ### 🛠️ Available Actions 217 | 218 | * 🔐 **age**: Encrypts and decrypts files using [age](https://github.com/FiloSottile/age) 219 | * 📚 **book**: Builds and publishes a Jupyter Book 220 | * 📦 **build**: Builds a Python package and uploads artifacts 221 | * 📊 **coverage**: Generates and uploads code coverage reports 222 | * 🚀 **cradle**: Runs the qCradle tool 223 | * 🔍 **deptry**: Checks for dependency issues using deptry 224 | * 🐳 **docker**: Builds and pushes Docker images 225 | * 🔧 **environment**: Sets up Python environment with dependencies 226 | * ⚙️ **flow**: Tests GitHub workflows using act 227 | * 📓 **jupyter**: Runs Jupyter notebooks 228 | * 📄 **latex**: Compiles LaTeX documents 229 | * 🧪 **marimo**: Runs marimo notebooks 230 | * 📝 **pdoc**: Generates API documentation using pdoc 231 | * ✅ **pre-commit**: Runs pre-commit hooks 232 | * 🏷️ **tag**: Bumps version, creates a tag, and publishes a release 233 | * 🧪 **test**: Runs tests with pytest 234 | 235 | ### 📋 How to Use These Actions 236 | 237 | You can use these actions in your GitHub workflows 238 | by referencing them with the `uses` keyword. For example: 239 | 240 | ```yaml 241 | jobs: 242 | tag: 243 | runs-on: ubuntu-latest 244 | steps: 245 | - name: Checkout 246 | uses: actions/checkout@v4 247 | 248 | - name: Generate Tag 249 | uses: tschm/cradle/actions/tag@main 250 | with: 251 | github_token: ${{ secrets.GITHUB_TOKEN }} 252 | ``` 253 | 254 | Replace `tschm/cradle` with the appropriate repository 255 | owner and name, and `@main` with the branch, tag, or commit SHA you want to use. 256 | 257 | Each action has its own inputs and outputs defined in 258 | its `action.yml` file. You can find more details by 259 | examining these files in the repository. 260 | 261 | ## 🐳 Docker Images 262 | 263 | This repository provides a versatile Docker image 264 | that can be used by various GitHub Actions. 265 | 266 | ### 🛠️ Multi-purpose Action Docker Image 267 | 268 | The repository includes a custom Docker image 269 | that serves as a comprehensive environment for running various GitHub Actions: 270 | 271 | * 🖥️ **Base**: Ubuntu 22.04 272 | * 🔧 **Development Tools**: 273 | * Node.js 20 with npm 274 | * Python 3 with pip, venv, and dev packages 275 | * Git, curl, wget, zip/unzip, jq 276 | * Build essentials and other utilities 277 | 278 | * 📄 **Document Processing**: 279 | * Tectonic (LaTeX compiler) 280 | * Biber (Bibliography processor) 281 | 282 | * 📦 **Pre-installed Python Packages**: 283 | * 🧪 Testing: pytest, pytest-cov, pytest-html, pytest-random-order 284 | * 📚 Documentation: jupyter-book, sphinx-math-dollar, pdoc 285 | * 📓 Notebooks: marimo 286 | * 📊 Data processing: pandas, toml, requests, packaging 287 | 288 | The Dockerfile for this image is located in the `docker` directory. 289 | The image is built and pushed to GitHub Container Registry (ghcr.io) 290 | using the GitHub workflow defined in `.github/workflows/docker.yml`. 291 | 292 | ### 🚀 Using the Docker Image 293 | 294 | This image is currently used by the flow action to test GitHub workflows 295 | using the [act](https://github.com/nektos/act) tool, but it can be used 296 | for various other actions as well. 297 | The image is designed to be a comprehensive environment that includes 298 | most tools and dependencies needed by the actions in this repository. 299 | 300 | You can use this image in your own workflows by referencing it in your workflow file: 301 | 302 | ```yaml 303 | jobs: 304 | test: 305 | runs-on: ubuntu-latest 306 | container: 307 | image: ghcr.io/tschm/cradle/flow-action:latest 308 | steps: 309 | - name: Checkout 310 | uses: actions/checkout@v4 311 | 312 | # Your steps here 313 | ``` 314 | 315 | The image is particularly useful for: 316 | 317 | * Running tests with coverage reporting 318 | * Building documentation (LaTeX, Jupyter Book, pdoc) 319 | * Processing Marimo notebooks 320 | * Analyzing dependencies 321 | * Testing GitHub workflows locally 322 | 323 | ## :warning: Private repositories 324 | 325 | Using workflows in private repos will eat into your monthly GitHub bill. 326 | You may want to restrict the workflow to operate only when merging on the main branch 327 | while operating on a different branch or deactivate the flow. 328 | -------------------------------------------------------------------------------- /actions/age/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Age of Dependencies' 2 | description: 'Run a check on the age of dependencies' 3 | 4 | inputs: 5 | python-version: 6 | description: 'The Python we shall use' 7 | required: false 8 | default: '3.12' 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ inputs.python-version }} 17 | 18 | - name: Install dependencies 19 | shell: bash 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install toml requests packaging pandas 23 | mkdir -p artifacts/age 24 | 25 | - name: Check dependency ages 26 | shell: bash 27 | run: | 28 | python - <> $GITHUB_PATH 25 | echo "${{ github.workspace }}/.venv/bin" >> $GITHUB_PATH 26 | 27 | # Ensure the venv/bin is first in the PATH 28 | unset PYTHONHOME # Unset the PYTHONHOME environment variable 29 | # if set to avoid any system Python fallback 30 | 31 | 32 | # Install UV (Windows) 33 | - name: Install UV (Windows) 34 | if: runner.os == 'Windows' 35 | shell: pwsh 36 | run: | 37 | irm https://astral.sh/uv/install.ps1 | iex 38 | $uvPath = "C:\Users\runneradmin\.local\bin" 39 | Add-Content $env:GITHUB_PATH $uvPath 40 | 41 | # Modify PATH to only include the virtual environment's Scripts directory 42 | Add-Content $env:GITHUB_PATH ${{ github.workspace }}\.venv\Scripts 43 | 44 | # Unset PYTHONHOME to avoid any system Python being used 45 | Remove-Item -Path Env:PYTHONHOME -ErrorAction SilentlyContinue 46 | 47 | - name: Install pip and create venv 48 | shell: bash 49 | run: | 50 | # Create virtual environment with uv 51 | uv venv --python ${{ inputs.python-version }} 52 | 53 | - name: Install dependencies if requirements.txt exists 54 | shell: bash 55 | run: | 56 | if [ -f "requirements.txt" ]; then 57 | uv pip install -r requirements.txt 58 | else 59 | # Sync environment (install dependencies) 60 | if [ -f "uv.lock" ]; then 61 | uv sync --all-extras --dev --frozen # Install all dependencies (including dev dependencies) 62 | else 63 | uv sync --all-extras --dev # Install all dependencies (including dev dependencies) 64 | fi 65 | fi 66 | 67 | uv pip install pip 68 | -------------------------------------------------------------------------------- /actions/flow/action.yml: -------------------------------------------------------------------------------- 1 | name: flow 2 | description: "Test a flow" 3 | 4 | inputs: 5 | workflow: 6 | description: 'Workflow, e.g. marimo.yml' 7 | required: true 8 | working-directory: 9 | description: 'Directory where the Python project is located' 10 | required: false 11 | default: 'template' 12 | 13 | runs: 14 | using: "composite" 15 | steps: 16 | - name: Install act 17 | shell: bash 18 | run: | 19 | curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash 20 | # Add the installation directory to PATH 21 | echo "${{ github.workspace }}/bin" >> $GITHUB_PATH 22 | 23 | - name: Verify act installation 24 | shell: bash 25 | run: | 26 | act --version 27 | 28 | - name: Test workflow 29 | shell: bash 30 | working-directory: ${{ inputs.working-directory }} 31 | run: | 32 | act -W .github/workflows/${{ inputs.workflow}} -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest --env ACT='true' 2>&1 | grep -v "remote not found" 33 | -------------------------------------------------------------------------------- /actions/jupyter/action.yml: -------------------------------------------------------------------------------- 1 | name: Build the Jupyter Book 2 | description: "Build jupyter book" 3 | 4 | runs: 5 | using: "composite" 6 | 7 | steps: 8 | - name: Install jupyterbook 9 | shell: bash 10 | run: | 11 | python -m pip install --no-cache-dir jupyter-book sphinx-math-dollar 12 | 13 | # Build the book 14 | - name: Build the book 15 | shell: bash 16 | run: | 17 | jupyter-book clean book 18 | jupyter-book build book 19 | 20 | # Upload the book 21 | - name: Archive book 22 | if: ${{ env.ACT != 'true' }} # Skip if running with 'act' 23 | uses: actions/upload-artifact@v4 24 | with: 25 | name: book 26 | path: book/_build/html/ 27 | retention-days: 1 28 | -------------------------------------------------------------------------------- /actions/latex/action.yml: -------------------------------------------------------------------------------- 1 | name: Compile and Deploy LaTeX Documents 2 | 3 | description: "Advanced LaTeX document compilation with error handling and artifacts" 4 | 5 | inputs: 6 | tag: 7 | description: 'tag' 8 | type: string 9 | required: false 10 | default: '' 11 | paper: 12 | description: 'Space-separated LaTeX files to compile' 13 | type: string 14 | required: true 15 | output-folder: 16 | description: 'Output directory for compiled documents' 17 | type: string 18 | required: false 19 | default: 'compiled' 20 | draft: 21 | description: 'Target branch for deployment' 22 | type: string 23 | required: false 24 | default: 'draft' 25 | 26 | 27 | runs: 28 | using: "composite" 29 | steps: 30 | - name: Set up Git repository 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup environment variables 34 | shell: bash 35 | run: | 36 | echo "BUILD_DIR=build_${{ github.run_id }}" >> $GITHUB_ENV 37 | 38 | - name: Create output directories 39 | shell: bash 40 | run: | 41 | mkdir -p ${{ env.BUILD_DIR }} 42 | mkdir -p ${{ inputs.output-folder }} 43 | 44 | - name: Validate input files 45 | shell: bash 46 | run: | 47 | for f in ${{ inputs.paper }}; do 48 | if [ ! -f "$f" ]; then 49 | echo "Error: File $f not found!" 50 | exit 1 51 | fi 52 | done 53 | 54 | - name: Install Tectonic and biber 55 | shell: bash 56 | run: | 57 | # install tectonic in the directory you run from 58 | curl --proto '=https' --tlsv1.2 -fsSL https://drop-sh.fullyjustified.net | sh 59 | # install biber 60 | sudo apt-get update -y 61 | sudo apt-get install -y biber 62 | 63 | ./tectonic --version 64 | biber --version 65 | 66 | - name: Compile LaTeX documents 67 | shell: bash 68 | env: 69 | RUST_BACKTRACE: '1' 70 | run: | 71 | echo "Starting compilation at $(date)" 72 | 73 | # Function to compile a single document 74 | compile_doc() { 75 | local doc="$1" 76 | echo "Compiling $doc" 77 | 78 | # First pass 79 | if ! ./tectonic "$doc" --outdir ${{ env.BUILD_DIR }} --keep-logs; then 80 | echo "Error compiling $doc - check logs" 81 | return 1 82 | fi 83 | 84 | # Copy final PDF to publish directory 85 | cp ${{ env.BUILD_DIR }}/*.pdf ${{ inputs.output-folder }}/ 86 | echo "Successfully compiled $doc" 87 | } 88 | 89 | for f in ${{ inputs.paper }}; do 90 | compile_doc "$f" || exit 1 91 | done 92 | 93 | - name: Deploy to GitHub Pages 94 | if: ${{ env.ACT != 'true' }} # Skip if running with 'act' 95 | uses: JamesIves/github-pages-deploy-action@v4 96 | with: 97 | branch: ${{ inputs.draft }} 98 | folder: ${{ inputs.output-folder }} 99 | commit-message: "Build: ${{ github.run_id }} [skip ci]" 100 | clean: true 101 | single-commit: false 102 | 103 | - name: Create GitHub Release 104 | if: inputs.tag != '' 105 | uses: softprops/action-gh-release@v2 106 | with: 107 | files: ${{ inputs.output-folder }}/*.pdf 108 | tag_name: ${{ inputs.tag }} 109 | 110 | - name: Upload build artifacts 111 | if: ${{ env.ACT != 'true' }} # Skip if running with 'act' 112 | uses: actions/upload-artifact@v4 113 | with: 114 | name: latex-build-${{ github.run_id }} 115 | path: | 116 | ${{ env.BUILD_DIR }} 117 | retention-days: 7 118 | -------------------------------------------------------------------------------- /actions/marimo/action.yml: -------------------------------------------------------------------------------- 1 | name: Convert Marimo to HTML 2 | description: "Converts Marimo .py files to HTML format for documentation" 3 | 4 | inputs: 5 | source_folder: 6 | description: 'Source directory containing Marimo .py files' 7 | required: false 8 | default: 'book/marimo' 9 | 10 | command: 11 | description: 'command' 12 | required: false 13 | default: 'export html "$file" -o "artifacts/marimo/${filename}/index.html"' 14 | 15 | runs: 16 | using: composite 17 | steps: 18 | - name: Install marimo 19 | shell: bash 20 | run: | 21 | python -m pip install --no-cache-dir marimo 22 | 23 | - name: Create output directory 24 | shell: bash 25 | run: | 26 | mkdir -p artifacts/marimo 27 | 28 | - name: Convert Marimo files to HTML 29 | shell: bash 30 | run: | 31 | if [ ! -d "${{ inputs.source_folder }}" ]; then 32 | echo "Error: Source directory ${{ inputs.source_folder }} does not exist" 33 | exit 1 34 | fi 35 | 36 | found_files=0 37 | for file in "${{ inputs.source_folder }}"/*.py; do 38 | if [ -f "$file" ]; then 39 | found_files=1 40 | filename=$(basename "$file" .py) 41 | echo "Converting $filename.py to HTML..." 42 | uv run marimo ${{ inputs.command }} 43 | #export html "$file" -o "artifacts/marimo/${filename}.html" 44 | fi 45 | done 46 | 47 | if [ "$found_files" -eq 0 ]; then 48 | echo "Warning: No .py files found in ${{ inputs.source_folder }}" 49 | exit 0 50 | fi 51 | 52 | - name: Upload HTML artifacts 53 | if: ${{ env.ACT != 'true' }} # Skip if running with 'act' 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: marimo 57 | path: artifacts/marimo 58 | retention-days: 1 59 | -------------------------------------------------------------------------------- /actions/pdoc/action.yml: -------------------------------------------------------------------------------- 1 | name: Build pdoc documentation 2 | description: "Build pdoc documentation" 3 | 4 | inputs: 5 | source-folder: 6 | description: 'Source folder to generate documentation for' 7 | type: string 8 | required: false 9 | default: 'cvx' 10 | pdoc-arguments: 11 | description: 'Additional pdoc command line arguments' 12 | type: string 13 | required: false 14 | default: '' 15 | 16 | 17 | runs: 18 | 19 | 20 | using: "composite" 21 | steps: 22 | - name: Install and build pdoc 23 | shell: bash 24 | run: | 25 | mkdir -p artifacts/pdoc 26 | python -m pip install --no-cache-dir pdoc 27 | pdoc -o artifacts/pdoc ${{ inputs.pdoc-arguments }} ${{ inputs.source-folder }} 28 | 29 | - name: Upload documentation 30 | if: ${{ env.ACT != 'true' }} # Skip if running with 'act' 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: pdoc 34 | path: artifacts/pdoc 35 | retention-days: 1 36 | -------------------------------------------------------------------------------- /actions/pre-commit/action.yml: -------------------------------------------------------------------------------- 1 | name: Check all pre-commit hooks on all files 2 | description: "Check all pre-commit hooks" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Checkout [${{ github.repository }}] 8 | uses: actions/checkout@v4 9 | 10 | - name: Install Node 20 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: '22' 14 | 15 | - uses: pre-commit/action@v3.0.1 16 | with: 17 | extra_args: '--verbose --all-files' 18 | -------------------------------------------------------------------------------- /actions/tag/action.yml: -------------------------------------------------------------------------------- 1 | name: "Bump version and create release" 2 | description: "Bumps version, creates a tag, and publishes a release." 3 | 4 | inputs: 5 | github_token: 6 | description: "GitHub token for authentication" 7 | required: true 8 | 9 | outputs: 10 | new_tag: 11 | description: "New tag" 12 | value: ${{ steps.tag_version.outputs.new_tag }} 13 | 14 | runs: 15 | using: "composite" 16 | 17 | steps: 18 | # ----------------------------------------------------------------------------- 19 | # Step 1: Bump version and tag 20 | # ----------------------------------------------------------------------------- 21 | - name: Bump version and tag 22 | id: tag_version 23 | uses: mathieudutour/github-tag-action@v6.2 24 | with: 25 | github_token: ${{ inputs.github_token }} 26 | 27 | # ----------------------------------------------------------------------------- 28 | # Step 2: Create GitHub release 29 | # ----------------------------------------------------------------------------- 30 | - name: Create GitHub release without artifacts 31 | uses: softprops/action-gh-release@v2 32 | with: 33 | tag_name: ${{ steps.tag_version.outputs.new_tag }} 34 | generate_release_notes: true 35 | 36 | - name: Debug Output Tag 37 | shell: bash 38 | run: | 39 | echo "DEBUG: ${{ steps.tag_version.outputs.new_tag }}" 40 | echo "new_tag=${{ steps.tag_version.outputs.new_tag }}" >> "$GITHUB_OUTPUT" 41 | -------------------------------------------------------------------------------- /actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | description: "Run pytest test suite" 3 | 4 | inputs: 5 | tests-folder: 6 | description: 'Source folder with all tests' 7 | required: false 8 | default: 'tests' 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Run tests 14 | shell: bash 15 | run: | 16 | python -m pip install --no-cache-dir pytest 17 | pytest ${{ inputs.tests-folder }} 18 | -------------------------------------------------------------------------------- /book/_config.yml: -------------------------------------------------------------------------------- 1 | # Book settings 2 | # Learn more at https://jupyterbook.org/customize/config.html 3 | 4 | title: cradle 5 | author: Thomas Schmelzer 6 | only_build_toc_files: true 7 | 8 | parse: 9 | myst_enable_extensions: 10 | - substitution 11 | - linkify 12 | - dollarmath 13 | myst_substitutions: 14 | book_url: https://tschm.github.io/cradle 15 | 16 | # needed for plotly 17 | sphinx: 18 | config: 19 | html_js_files: 20 | - https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.4/require.min.js 21 | 22 | # Information about where the book exists on the web 23 | repository: 24 | url: https://github.com/tschm/cradle 25 | path_to_book: book 26 | branch: main 27 | 28 | # Add GitHub buttons to your book 29 | # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository 30 | html: 31 | use_issues_button: true 32 | use_repository_button: true 33 | #use_edit_page_button: true 34 | extra_navbar: Powered by Jupyter Book # Will be displayed underneath the left navbar. 35 | -------------------------------------------------------------------------------- /book/_toc.yml: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | # Learn more at https://jupyterbook.org/customize/toc.html 3 | format: jb-book 4 | root: docs/index 5 | chapters: 6 | - file: docs/api 7 | - file: docs/reports 8 | 9 | - url: https://tschm.github.io/cradle/marimo/demo/index.html 10 | title: Demo 11 | -------------------------------------------------------------------------------- /book/docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | {{ '[API]({url}/pdoc/)'.format(url=book_url) }} 4 | -------------------------------------------------------------------------------- /book/docs/index.md: -------------------------------------------------------------------------------- 1 | ../../README.md -------------------------------------------------------------------------------- /book/docs/reports.md: -------------------------------------------------------------------------------- 1 | # Test Reports 2 | 3 | ## Timing 4 | 5 | {{ '[Report]({url}/tests/html-report/report.html)'.format(url=book_url) }} 6 | 7 | ## Coverage 8 | 9 | {{ '[Coverage]({url}/tests/html-coverage/index.html)'.format(url=book_url) }} 10 | -------------------------------------------------------------------------------- /book/marimo/demo.py: -------------------------------------------------------------------------------- 1 | """Demo.""" 2 | 3 | import marimo 4 | 5 | __generated_with = "0.10.10" 6 | app = marimo.App() 7 | 8 | 9 | @app.cell 10 | def _(): 11 | import marimo as mo 12 | 13 | return (mo,) 14 | 15 | 16 | @app.cell 17 | def _(mo): 18 | mo.md(r"""# Demo""") 19 | return 20 | 21 | 22 | if __name__ == "__main__": 23 | app.run() 24 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tschm/cradle/814298f157458582adf4472535b81ded226a70ae/demo.png -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | # Set environment variables 4 | ENV DEBIAN_FRONTEND=noninteractive \ 5 | PYTHONDONTWRITEBYTECODE=1 \ 6 | PYTHONUNBUFFERED=1 \ 7 | PATH="/home/runner/.local/bin:${PATH}" 8 | 9 | # Install system dependencies in a single layer with cleanup 10 | RUN apt-get update && apt-get install -y --no-install-recommends \ 11 | ca-certificates \ 12 | curl \ 13 | wget \ 14 | git \ 15 | sudo \ 16 | software-properties-common \ 17 | build-essential \ 18 | #gnupg \ 19 | #zip \ 20 | #unzip \ 21 | #jq \ 22 | python3 \ 23 | #python3-pip \ 24 | #python3-venv \ 25 | #python3-dev \ 26 | #python-is-python3 \ 27 | #biber \ 28 | && apt-get clean \ 29 | && rm -rf /var/lib/apt/lists/* 30 | 31 | # Install Node.js 20 using NodeSource recommended method 32 | RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ 33 | && apt-get update \ 34 | && apt-get install -y nodejs \ 35 | && npm install -g npm@latest \ 36 | && rm -rf /var/lib/apt/lists/* 37 | 38 | # Create non-root user with fixed UID for consistency 39 | RUN useradd -m -u 1001 -s /bin/bash runner \ 40 | && echo "runner ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers 41 | 42 | # Switch to runner user for subsequent operations 43 | USER runner 44 | WORKDIR /home/runner 45 | 46 | # Copy requirements file first for better caching 47 | COPY --chown=runner:runner docker/requirements.txt . 48 | 49 | # Install Python packages using recommended method 50 | #RUN python3 -m pip install --no-cache-dir --upgrade pip \ 51 | # && python3 -m pip install --no-cache-dir -r requirements.txt 52 | 53 | # Label the image 54 | LABEL maintainer="cradle" \ 55 | description="Docker image for running GitHub Actions workflows" \ 56 | version="1.0" \ 57 | org.opencontainers.image.source="https://github.com/tschm/cradle" 58 | 59 | # Default command 60 | CMD ["/bin/bash"] 61 | -------------------------------------------------------------------------------- /docker/push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script builds and pushes a Docker image to GitHub Container Registry (ghcr.io) 3 | # Prerequisites: 4 | # 1. You need to create a Personal Access Token (PAT) with 'write:packages' scope 5 | # at https://github.com/settings/tokens 6 | # 2. Export your GitHub username and PAT as environment variables: 7 | # export GITHUB_USERNAME=your-username 8 | # export GITHUB_TOKEN=your-personal-access-token 9 | # 10 | # Alternatively, you can pass them as arguments: 11 | # ./push.sh 12 | 13 | # Get GitHub credentials either from arguments or environment variables 14 | GITHUB_USERNAME=${1:-$GITHUB_USERNAME} 15 | GITHUB_TOKEN=${2:-$GITHUB_TOKEN} 16 | 17 | # Check if credentials are provided 18 | if [ -z "$GITHUB_USERNAME" ] || [ -z "$GITHUB_TOKEN" ]; then 19 | echo "Error: GitHub username and token are required." 20 | echo "Usage: ./push.sh " 21 | echo "Or set environment variables GITHUB_USERNAME and GITHUB_TOKEN" 22 | exit 1 23 | fi 24 | 25 | # Login to GitHub Container Registry 26 | echo "Logging in to GitHub Container Registry..." 27 | echo $GITHUB_TOKEN | docker login ghcr.io -u $GITHUB_USERNAME --password-stdin 28 | 29 | # Build the Docker image 30 | echo "Building Docker image..." 31 | 32 | docker buildx create --use # Enable buildx 33 | docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/tschm/cradle:latest --push -f docker/Dockerfile . 34 | 35 | #docker build -t ghcr.io/tschm/cradle:latest . 36 | 37 | # Push the Docker image 38 | # echo "Pushing Docker image to ghcr.io..." 39 | # docker push ghcr.io/tschm/cradle:latest 40 | 41 | # Logout for security 42 | echo "Logging out from GitHub Container Registry..." 43 | docker logout ghcr.io 44 | 45 | echo "Done!" 46 | -------------------------------------------------------------------------------- /docker/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Project metadata and package information 2 | [project] 3 | name = "qCradle" 4 | version = "0.0.0" 5 | description = "CLI to create repos" 6 | readme = "README.md" 7 | authors = [{ name = "Thomas Schmelzer", email = "thomas.schmelzer@gmail.com" }] 8 | requires-python = ">=3.10" 9 | dependencies = [ 10 | "copier==9.7.1", 11 | "questionary==2.1.0", 12 | "loguru==0.7.3", 13 | "fire==0.7.0", 14 | "pyyaml==6.0.2", 15 | "security==1.3.1", 16 | "gitpython>=3.1.44", 17 | ] 18 | 19 | # URLs related to the project 20 | [project.urls] 21 | repository = "https://github.com/cvxgrp/cradle" 22 | 23 | # Optional dependencies for development 24 | [project.optional-dependencies] 25 | dev = [ 26 | "pytest-cov==6.1.1", 27 | "pytest==8.4.0", 28 | "pre-commit==4.2.0", 29 | "pytest-mock==3.14.1" 30 | ] 31 | 32 | # Entry points for command-line scripts 33 | [project.scripts] 34 | qcradle = "cradle.cli:main" 35 | 36 | # Ruff linter configuration 37 | [tool.ruff] 38 | line-length = 120 39 | target-version = "py310" 40 | exclude = [ 41 | "*__init__.py" 42 | ] 43 | 44 | # Linting rules configuration 45 | [tool.ruff.lint] 46 | # Available rule sets in Ruff: 47 | # A: flake8-builtins - Check for python builtins being used as variables or parameters 48 | # B: flake8-bugbear - Find likely bugs and design problems 49 | # C4: flake8-comprehensions - Helps write better list/set/dict comprehensions 50 | # D: pydocstyle - Check docstring style 51 | # E: pycodestyle errors - PEP 8 style guide 52 | # ERA: eradicate - Find commented out code 53 | # F: pyflakes - Detect logical errors 54 | # I: isort - Sort imports 55 | # N: pep8-naming - Check PEP 8 naming conventions 56 | # PT: flake8-pytest-style - Check pytest best practices 57 | # RUF: Ruff-specific rules 58 | # S: flake8-bandit - Find security issues 59 | # SIM: flake8-simplify - Simplify code 60 | # T10: flake8-debugger - Check for debugger imports and calls 61 | # UP: pyupgrade - Upgrade syntax for newer Python 62 | # W: pycodestyle warnings - PEP 8 style guide warnings 63 | # ANN: flake8-annotations - Type annotation checks 64 | # ARG: flake8-unused-arguments - Unused arguments 65 | # BLE: flake8-blind-except - Check for blind except statements 66 | # COM: flake8-commas - Trailing comma enforcement 67 | # DTZ: flake8-datetimez - Ensure timezone-aware datetime objects 68 | # EM: flake8-errmsg - Check error message strings 69 | # FBT: flake8-boolean-trap - Boolean argument checks 70 | # ICN: flake8-import-conventions - Import convention enforcement 71 | # ISC: flake8-implicit-str-concat - Implicit string concatenation 72 | # NPY: NumPy-specific rules 73 | # PD: pandas-specific rules 74 | # PGH: pygrep-hooks - Grep-based checks 75 | # PIE: flake8-pie - Miscellaneous rules 76 | # PL: Pylint rules 77 | # Q: flake8-quotes - Quotation style enforcement 78 | # RSE: flake8-raise - Raise statement checks 79 | # RET: flake8-return - Return statement checks 80 | # SLF: flake8-self - Check for self references 81 | # TCH: flake8-type-checking - Type checking imports 82 | # TID: flake8-tidy-imports - Import tidying 83 | # TRY: flake8-try-except-raise - Try/except/raise checks 84 | # YTT: flake8-2020 - Python 2020+ compatibility 85 | 86 | # Current configuration: 87 | select = ["E", "F", "I", "N", "D", "UP", "NPY", "PD", "C4", "B", "S", "W"] 88 | 89 | # Per-file rule ignores 90 | [tool.ruff.lint.per-file-ignores] 91 | "src/tests/**/*.py" = ["S101"] # Allow assert statements in tests 92 | "book/marimo/*.py" = ["N803", "S101"] 93 | 94 | # Ruff linter rule selection 95 | #[tool.ruff.lint] 96 | #select = ["E", "F", "I"] 97 | 98 | # Build system configuration 99 | [build-system] 100 | requires = ["hatchling"] 101 | build-backend = "hatchling.build" 102 | 103 | # Hatch wheel build configuration 104 | [tool.hatch.build.targets.wheel] 105 | packages = ["src/cradle"] 106 | 107 | # Hatch build configuration - files to include in the package 108 | [tool.hatch.build] 109 | include = [ 110 | "LICENSE", # Ensure the LICENSE file is included in your package 111 | "README.md", 112 | "demo.png", 113 | "src/cradle", 114 | ] 115 | 116 | # Deptry dependency checker configuration 117 | [tool.deptry] 118 | # see https://deptry.com/usage/#pep-621-dev-dependency-groups 119 | pep621_dev_dependency_groups = ["dev"] 120 | -------------------------------------------------------------------------------- /src/cradle/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | __version__ = importlib.metadata.version("qCradle") 4 | -------------------------------------------------------------------------------- /src/cradle/cli.py: -------------------------------------------------------------------------------- 1 | """Command-line interface for qCradle.""" 2 | 3 | import datetime 4 | import os 5 | import shutil 6 | import sys 7 | import tempfile 8 | from pathlib import Path 9 | 10 | import copier 11 | import questionary 12 | import yaml 13 | from fire import Fire 14 | from loguru import logger 15 | 16 | from .utils.gh_client import setup_repository 17 | from .utils.git import assert_git_version 18 | from .utils.questions import ask 19 | 20 | # from .utils.shell import run_shell_command 21 | 22 | # Add a new logger with a simpler format 23 | logger.remove() # Remove the default logger 24 | logger.add( 25 | sys.stdout, 26 | colorize=True, # Enable color output 27 | format="{time:YYYY-MM-DD HH:mm:ss} | " 28 | "{level: <8} | {function}:{line} | {message}", 29 | ) 30 | 31 | 32 | def load_templates(yaml_path: Path) -> dict[str, str]: 33 | """Load templates from YAML file and return a dictionary mapping display names to URLs.""" 34 | with open(yaml_path) as f: 35 | config = yaml.safe_load(f) 36 | 37 | return {details["display_name"]: details["url"] for template_name, details in config["templates"].items()} 38 | 39 | 40 | def append_to_yaml_file(new_data, file_path): 41 | """Append or update a YAML file with new data. 42 | 43 | This function checks if the 44 | specified YAML file exists. If it exists, the current contents are loaded, 45 | updated with the new data, and written back to the file. If the file does not 46 | exist, a new file is created, and the provided data is written to it. 47 | 48 | Parameters 49 | ---------- 50 | new_data : dict 51 | The new data to append to the YAML file. It should be a dictionary. 52 | file_path : str 53 | The path of the YAML file to update or create. 54 | 55 | Raises 56 | ------ 57 | yaml.YAMLError 58 | If there is an error in parsing or dumping YAML data. 59 | IOError 60 | If there is an issue with file reading or writing. 61 | 62 | """ 63 | # Check if the file exists 64 | if os.path.exists(file_path): 65 | # Load the existing data from the file 66 | with open(file_path) as file: 67 | existing_data = yaml.safe_load(file) or {} # Load existing data or empty dict if file is empty 68 | else: 69 | # If the file doesn't exist, start with an empty dict 70 | existing_data = {} 71 | 72 | # Append new data (update or add new keys) 73 | existing_data.update(new_data) 74 | 75 | # Write the updated data back to the YAML file 76 | with open(file_path, "w") as file: 77 | yaml.dump(existing_data, file, default_flow_style=False) 78 | 79 | 80 | # Load defaults from .copier-answers.yml 81 | def load_defaults(file_path=".copier-answers.yml"): 82 | """Load default values from a specified YAML file. 83 | 84 | If the file is missing, 85 | it returns an empty dictionary. If the file is present but cannot be 86 | parsed due to invalid YAML formatting, an error message is printed and 87 | the exception is re-raised. 88 | 89 | Parameters 90 | ---------- 91 | file_path: str 92 | The path to the YAML file from which defaults should be loaded. Defaults to 93 | ".copier-answers.yml". 94 | 95 | Returns 96 | ------- 97 | dict 98 | A dictionary containing the parsed contents of the YAML file. If the file is 99 | not found, an empty dictionary is returned. 100 | 101 | Raises 102 | ------ 103 | yaml.YAMLError 104 | If there is an error while parsing the YAML file, this exception is raised. 105 | 106 | """ 107 | try: 108 | with open(file_path) as file: 109 | return yaml.safe_load(file) or {} 110 | except FileNotFoundError: 111 | return {} # Return empty dict if the file is missing 112 | except yaml.YAMLError as e: 113 | print(f"⚠️ Error parsing YAML file: {e}") 114 | raise e 115 | 116 | 117 | def cli(template: str = None, dst_path: str = None, vcs_ref: str | None = None, **kwargs) -> None: 118 | """Create GitHub repositories from the command line. 119 | 120 | It is also possible to create a large number of GitHub repositories. 121 | 122 | Args: 123 | template: optional (str) template. Use a git URI, e.g. 'git@...'. 124 | Offers a group of standard templates to choose from if not specified. 125 | 126 | dst_path: optional (str) destination path. Useful when updating existing projects. 127 | It has to be a full path. When given the template is ignored. 128 | 129 | vcs_ref: optional (str) revision number to checkout 130 | a particular Git ref before generating the project. 131 | 132 | **kwargs: optional keyword arguments to pass to copier.run_copy() or copier.run_update() 133 | 134 | """ 135 | # check the git version 136 | assert_git_version(min_version="2.28.0") 137 | 138 | # answer a bunch of questions 139 | logger.info("The qCradle will ask a group of questions to create a repository for you") 140 | 141 | home = os.getcwd() 142 | 143 | if dst_path is None: 144 | if template is None: 145 | # Load templates from YAML file 146 | yaml_path = Path(__file__).parent / "templates.yaml" # Adjust path as needed 147 | templates = load_templates(yaml_path) 148 | 149 | # Let user select from the display names 150 | result = questionary.select( 151 | "What kind of project do you want to create?", 152 | choices=list(templates.keys()), 153 | ).ask() 154 | 155 | template = templates[result] 156 | remove_path = True 157 | update = False 158 | dst_path = Path(tempfile.mkdtemp()) 159 | logger.info(f"No destination path specified. Use {dst_path}") 160 | defaults = {} 161 | os.chdir(dst_path) 162 | 163 | else: 164 | logger.info(f"Destination path specified. Use {dst_path}") 165 | remove_path = False 166 | update = True 167 | os.chdir(dst_path) 168 | 169 | defaults = load_defaults(".copier-answers.yml") 170 | 171 | context = ask(logger=logger, defaults=defaults) 172 | 173 | logger.info("*** Copier is parsing the template ***") 174 | 175 | # Copy material into the random path 176 | if update: 177 | copier.run_update(dst_path, data=context, overwrite=True, **kwargs) 178 | timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") 179 | branch = f"update-qcradle-{timestamp}" 180 | 181 | setup_repository(dst_path, context=context, branch=branch) 182 | # Wrap with Repo object 183 | # repo = Repo(dst_path) 184 | # repo.git.checkout(branch) 185 | # repo.git.add(all=True) 186 | # repo.git.commit("-m", "Updates by qcradle") 187 | 188 | # Push to origin main 189 | # repo.remotes.origin.push(refspec=f"{branch}:{branch}") 190 | 191 | else: 192 | logger.info(f"{context}") 193 | copier.run_copy(template, dst_path, data=context, vcs_ref=vcs_ref, **kwargs) 194 | append_to_yaml_file(new_data=context, file_path=".copier-answers.yml") 195 | setup_repository(dst_path, context=context, branch="main") 196 | 197 | # go back to the repo 198 | os.chdir(home) 199 | 200 | # delete the path you have created 201 | if remove_path: 202 | shutil.rmtree(dst_path) 203 | 204 | if not update: 205 | logger.info(f"\n\nYou may have to perform 'git clone {context['ssh_uri']}'") 206 | 207 | 208 | def main(): # pragma: no cover 209 | """Run the CLI using Fire.""" 210 | Fire(cli) 211 | -------------------------------------------------------------------------------- /src/cradle/templates.yaml: -------------------------------------------------------------------------------- 1 | templates: 2 | marimo_experiments: 3 | url: "git@github.com:tschm/experiments.git" 4 | display_name: "(Marimo) Experiments" 5 | package: 6 | url: "git@github.com:tschm/package.git" 7 | display_name: "A python package" 8 | paper: 9 | url: "git@github.com:tschm/paper.git" 10 | display_name: "A LaTeX document" 11 | r: 12 | url: "git@github.com:tschm/cradle_r.git" 13 | display_name: "An R project" 14 | server: 15 | url: "git@github.com:tschm/server.git" 16 | display_name: "A (numpy) server" 17 | presentation: 18 | url: "git@github.com:tschm/presentation.git" 19 | display_name: "A (Marimo) presentation" 20 | -------------------------------------------------------------------------------- /src/cradle/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tschm/cradle/814298f157458582adf4472535b81ded226a70ae/src/cradle/utils/__init__.py -------------------------------------------------------------------------------- /src/cradle/utils/gh_client.py: -------------------------------------------------------------------------------- 1 | """GitHub CLI client for qCradle.""" 2 | 3 | import subprocess 4 | from pathlib import Path 5 | 6 | from git import Git, InvalidGitRepositoryError, Repo 7 | from security import safe_command 8 | 9 | 10 | class GitHubCLI: 11 | """GitHub CLI client for qCradle.""" 12 | 13 | def __init__(self, verbose: bool = True): 14 | """Initialize the instance of the class and set verbosity level. 15 | 16 | Args: 17 | verbose (bool): Determines whether the instance operates in verbose mode. 18 | 19 | """ 20 | self.verbose = verbose 21 | 22 | def run(self, *args: str) -> str | None: 23 | """Execute a GitHub CLI command safely.""" 24 | cmd = ["gh", *args] 25 | if self.verbose: 26 | print(f"⚙️ Running: {' '.join(cmd)}") 27 | 28 | try: 29 | result = safe_command.run(subprocess.run, cmd, check=True, capture_output=not self.verbose, text=True) 30 | return result.stdout.strip() if not self.verbose else None 31 | except subprocess.CalledProcessError as e: 32 | if self.verbose: 33 | print(f"❌ Command failed: {e.stderr}") 34 | raise RuntimeError(f"GitHub CLI error: {e.stderr}") from e 35 | 36 | def create_repo(self, name: str, private: bool = False, description: str | None = None) -> str: 37 | """Create a new GitHub repository.""" 38 | args = ["repo", "create", name.replace(" ", "-")] 39 | args += ["--private"] if private else ["--public"] 40 | if description: 41 | args += ["--description", description] 42 | args += ["--confirm"] 43 | return self.run(*args) 44 | 45 | @staticmethod 46 | def version() -> str: 47 | """Verify GitHub CLI is installed.""" 48 | try: 49 | return safe_command.run(subprocess.run, ["git", "--version"], capture_output=True, text=True) 50 | except subprocess.CalledProcessError as e: 51 | raise subprocess.CalledProcessError("Git is not installed") from e 52 | 53 | 54 | def is_git_repo(path: Path) -> bool: 55 | """Check if path contains a valid Git repository using pathlib only.""" 56 | try: 57 | git_dir = path / ".git" 58 | return git_dir.exists() or bool(Repo(str(path)).git_dir) 59 | except InvalidGitRepositoryError: 60 | return False 61 | except Exception as e: 62 | print(f"⚠️ Error checking Git repo: {e}") 63 | return False 64 | 65 | 66 | def setup_repository(dst_path: Path, context: dict[str, str], branch: str = "main") -> Repo: 67 | """Initialize or update a Git repository with GitHub integration.""" 68 | if not GitHubCLI.version(): 69 | raise RuntimeError("GitHub is not installed") 70 | 71 | # Convert to Path if not already 72 | dst_path = Path(dst_path) 73 | 74 | # Initialize or open repository 75 | if is_git_repo(dst_path): 76 | repo = Repo(str(dst_path)) 77 | repo.git.checkout(branch) 78 | initial = False 79 | else: 80 | Git(str(dst_path)).init(initial_branch=branch) 81 | repo = Repo(str(dst_path)) 82 | initial = True 83 | print("initial") 84 | 85 | # Stage all changes 86 | repo.git.add(A=True) 87 | 88 | # Commit changes 89 | commit_message = "Initial commit by qcradle" if initial else "Update by qcradle" 90 | repo.git.commit(m=commit_message) 91 | 92 | # Create remote repository if initial setup 93 | if initial: 94 | gh = GitHubCLI() 95 | if context["status"] == "public": 96 | private = False 97 | else: 98 | private = True 99 | 100 | gh.create_repo( 101 | name=f"{context['username']}/{context['project_name']}", 102 | private=private, 103 | description=context.get("description", ""), 104 | ) 105 | 106 | # Add remote if it doesn't exist 107 | if not any(r.name == "origin" for r in repo.remotes): 108 | repo.create_remote("origin", context["ssh_uri"]) 109 | 110 | # Push changes 111 | repo.remotes.origin.push(refspec=f"{branch}:{branch}") 112 | 113 | return repo 114 | -------------------------------------------------------------------------------- /src/cradle/utils/git.py: -------------------------------------------------------------------------------- 1 | """Git version checking utility using GitPython. 2 | 3 | This module provides functionality to check and validate Git version requirements 4 | using the GitPython package, with support for vendor-specific version formats. 5 | """ 6 | 7 | import re 8 | from dataclasses import dataclass 9 | 10 | from .gh_client import GitHubCLI 11 | 12 | 13 | class GitVersionError(Exception): 14 | """Raised when there are issues with Git version requirements.""" 15 | 16 | 17 | class GitNotFoundError(GitVersionError): 18 | """Raised when Git is not installed or not accessible.""" 19 | 20 | 21 | @dataclass(frozen=True) 22 | class _GitVersion: 23 | """Represents a semantic Git version.""" 24 | 25 | major: int 26 | minor: int 27 | patch: int | None = None 28 | vendor_info: str | None = None 29 | 30 | def __ge__(self, other): 31 | if not isinstance(other, _GitVersion): 32 | raise TypeError(f"Cannot compare _GitVersion with {type(other)}") 33 | # Compare major, minor, and patch numbers 34 | return (self.major, self.minor, self.patch) >= ( 35 | other.major, 36 | other.minor, 37 | other.patch, 38 | ) 39 | 40 | 41 | def _check_git_version( 42 | min_version: str | tuple[int, int] | tuple[int, int, int], 43 | ) -> bool: 44 | """Check if the installed Git version meets minimum requirements. 45 | 46 | Args: 47 | min_version: Minimum required version, specified as either: 48 | - A string (e.g., "2.28.0" or "2.28") 49 | - A GitVersion object 50 | - A tuple of (major, minor) or (major, minor, patch) 51 | verbose: If True, print version information. 52 | 53 | Returns: 54 | bool: True if the installed version meets requirements. 55 | 56 | Raises: 57 | GitVersionError: For version parsing or comparison issues. 58 | GitNotFoundError: If Git is not installed. 59 | 60 | """ 61 | # Convert min_version to GitVersion object 62 | if isinstance(min_version, str): 63 | parts = min_version.split(".") 64 | min_version = _GitVersion(int(parts[0]), int(parts[1]), int(parts[2]) if len(parts) > 2 else None) 65 | elif isinstance(min_version, tuple): 66 | min_version = _GitVersion( 67 | min_version[0], 68 | min_version[1], 69 | min_version[2] if len(min_version) > 2 else None, 70 | ) 71 | 72 | # Run the git --version command using subprocess 73 | result = GitHubCLI.version() 74 | 75 | # Check if the command was successful 76 | if result.returncode == 0: 77 | # The version string is like: "git version 2.34.1" 78 | version_str = result.stdout.strip() # Remove any extra whitespace 79 | # Extract the version using a regular expression 80 | match = re.match(r"git version (\d+\.\d+\.\d+)", version_str) 81 | if match: 82 | installed_version = match.group(1).split(".") 83 | else: 84 | raise GitVersionError("Could not parse Git version") 85 | else: 86 | raise GitNotFoundError("Git is not installed or is not available in the PATH") 87 | 88 | installed_version = _GitVersion( 89 | major=int(installed_version[0]), 90 | minor=int(installed_version[1]), 91 | patch=int(installed_version[2]), 92 | ) 93 | 94 | return installed_version >= min_version 95 | 96 | 97 | def assert_git_version(min_version: str | tuple[int, int] | tuple[int, int, int]): 98 | """Assert that Git version meets minimum requirements. 99 | 100 | Args: 101 | min_version: Minimum required version (same format as check_git_version). 102 | 103 | Raises: 104 | GitVersionError: If version requirements are not met. 105 | GitNotFoundError: If Git is not installed. 106 | 107 | """ 108 | if not _check_git_version(min_version): 109 | raise GitVersionError(f"Insufficient Git version (required: >= {min_version})") 110 | -------------------------------------------------------------------------------- /src/cradle/utils/questions.py: -------------------------------------------------------------------------------- 1 | """Questionary-based CLI questions and validation.""" 2 | 3 | import logging 4 | import re 5 | 6 | import questionary 7 | 8 | 9 | def _validate_project_name(project_name): 10 | project_name = project_name.strip() 11 | if not re.match(r"^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$", project_name): 12 | raise ValueError( 13 | "Project name must start with a lowercase letter, followed by letters, digits, dashes, or underscores.\n" 14 | "It cannot start or end with '-' or '_'." 15 | ) 16 | return project_name 17 | 18 | 19 | def _validate_username(username): 20 | if not username: 21 | raise ValueError("Username cannot be empty.") 22 | return username.strip() 23 | 24 | 25 | def _validate_description(description): 26 | if not description: 27 | raise ValueError("Description cannot be empty.") 28 | return description.strip() 29 | 30 | 31 | def _validate_page(page): 32 | if not page: 33 | raise ValueError("Page cannot be empty.") 34 | return page.strip() 35 | 36 | 37 | def _validate_status(status): 38 | valid_statuses = {"public", "private", "internal"} 39 | if status not in valid_statuses: 40 | raise ValueError(f"Status must be one of: {', '.join(valid_statuses)}.") 41 | return status 42 | 43 | 44 | def ask(logger=None, defaults=None): 45 | """Prompt the user for project details and collect input, validate it, and log the repository information. 46 | 47 | This function interacts with the user to gather information required for creating a GitHub repository. 48 | 49 | Args: 50 | logger (Optional[logging.Logger]): Logger instance for logging messages. 51 | defaults (Optional[dict]): Dictionary containing default values for project details. 52 | 53 | Returns: 54 | dict: A dictionary containing the project details gathered from the user: 55 | - "project_name": The name of the project (str). 56 | - "username": The GitHub username (str). 57 | - "description": A brief description of the project (str). 58 | - "status": The visibility status of the repository, either "public" or "private" (str). 59 | - "ssh_uri": SSH URI for the repository (str). 60 | - "repository": The full GitHub repository URL (str). 61 | - "gh_create": The GitHub CLI command to create the repository (str). 62 | - "page": The URL for the project companion website (str). 63 | 64 | """ 65 | logger = logger or logging.getLogger(__name__) 66 | defaults = defaults or {} 67 | 68 | # Get user inputs with questionary (use defaults from YAML) 69 | project_name = questionary.text("Enter your project name:", default=defaults.get("project_name", "")).ask() 70 | project_name = _validate_project_name(project_name.lower()) 71 | 72 | username = questionary.text( 73 | "Enter your GitHub username (e.g. 'tschm' or 'cvxgrp'):", default=defaults.get("username", "") 74 | ).ask() 75 | username = _validate_username(username) 76 | 77 | description = questionary.text( 78 | "Enter a brief description of your project:", default=defaults.get("description", "") 79 | ).ask() 80 | description = _validate_description(description) 81 | 82 | page = questionary.text( 83 | "Companion website:", default=defaults.get("page", f"https://{username}.github.io/{project_name}") 84 | ).ask() 85 | page = _validate_page(page) 86 | 87 | status = questionary.select( 88 | "What is the visibility status of the repository?", 89 | choices=["public", "private"], 90 | default=defaults.get("status", "public"), 91 | ).ask() 92 | status = _validate_status(status) 93 | 94 | # Generate dynamic values 95 | repository_url = f"https://github.com/{username}/{project_name}" 96 | ssh_uri = f"git@github.com:{username}/{project_name}.git" 97 | gh_create = f"gh repo create {username}/{project_name} --{status} --description '{description}'" 98 | 99 | # Display the results 100 | print("\n--- Repository Details ---") 101 | print(f"📌 Project Name: {project_name}") 102 | print(f"👤 GitHub Username: {username}") 103 | print(f"📝 Description: {description}") 104 | print(f"🔒 Visibility: {status}") 105 | print(f"🔗 Repository URL: {repository_url}") 106 | print(f"🌐 Companion Page: {page}") 107 | print(f"🔑 SSH URI: {ssh_uri}") 108 | print(f"🛠️ Command to create the repo: {gh_create}\n") 109 | 110 | logger.info("Repository details collected successfully.") 111 | 112 | return { 113 | "project_name": project_name, 114 | "username": username, 115 | "description": description, 116 | "status": status, 117 | "ssh_uri": ssh_uri, 118 | "repository": repository_url, 119 | "gh_create": gh_create, 120 | "page": page, 121 | } 122 | -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest configuration file for the tests module.""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture() 9 | def resource_dir(): 10 | """Pytest fixture that provides the path to the test resources directory. 11 | 12 | Returns: 13 | Path: A Path object pointing to the resources directory within the tests folder. 14 | 15 | """ 16 | return Path(__file__).parent / "resources" 17 | -------------------------------------------------------------------------------- /src/tests/resources/.copier-answers.yml: -------------------------------------------------------------------------------- 1 | _commit: v0.4.21 2 | _src_path: git@github.com:tschm/experiments.git 3 | description: DemoXYZ updates 4 4 | gh_create: gh repo create tschm/xxx13 --public --description 'DemoXYZ updates 4' 5 | page: https://tschm.github.io/xxx13 6 | project_name: xxx13 7 | repository: https://github.com/tschm/xxx13 8 | ssh_uri: git@github.com:tschm/xxx13.git 9 | status: public 10 | username: tschm 11 | -------------------------------------------------------------------------------- /src/tests/resources/broken.yml: -------------------------------------------------------------------------------- 1 | _commit: v0.4.21 2 | _src_path: git@github.com:tschm/experiments.git 3 | description: DemoXYZ updates 4 4 | gh_create: gh repo create tschm/xxx13 --public --description 'DemoXYZ updates 4' 5 | page: https://tschm.github.io/xxx13 6 | project_name: xxx13 7 | repository: https://github.com/tschm/xxx13 8 | ssh_uri: git@github.com:tschm/xxx13.git 9 | -------------------------------------------------------------------------------- /src/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests for the CLI module.""" 2 | 3 | import dataclasses 4 | 5 | import pytest 6 | import yaml 7 | 8 | from cradle.cli import append_to_yaml_file, cli, load_defaults 9 | 10 | # @pytest.fixture 11 | # def mock_run_shell_command(mocker): 12 | # """Fixture to mock `run_shell_command`.""" 13 | # # Mock run_shell_command and return a MagicMock so we can check calls 14 | # return mocker.patch("cradle.cli.run_shell_command", autospec=True) 15 | 16 | 17 | @pytest.fixture 18 | def mock_context(): 19 | """Pytest fixture that provides a mock context dictionary for testing. 20 | 21 | Returns: 22 | dict: A dictionary containing mock project information including project name, 23 | username, SSH URI, status, and description. 24 | 25 | """ 26 | return { 27 | "project_name": "Mocked Project", 28 | "username": "Jane Doe", 29 | "ssh_uri": "git@github.com:jane/mocked-project.git", 30 | "status": "public", 31 | "description": "Mocked Project", 32 | } 33 | 34 | 35 | @dataclasses.dataclass(frozen=True) 36 | class Answer: 37 | """A mock class that simulates questionary's Answer objects for testing. 38 | 39 | Attributes: 40 | string (str): The string value to be returned when ask() is called. 41 | 42 | """ 43 | 44 | string: str 45 | 46 | def ask(self): 47 | """Simulate the ask method of questionary's Answer objects. 48 | 49 | Returns: 50 | str: The string value stored in this Answer object. 51 | 52 | """ 53 | return self.string 54 | 55 | 56 | def test_no_template(mock_context, mocker): 57 | """Test the CLI functionality when no template is specified. 58 | 59 | Args: 60 | mock_context (dict): Fixture providing mock project context. 61 | mocker (pytest.MockFixture): Pytest fixture for mocking. 62 | 63 | """ 64 | mocker.patch("cradle.cli.questionary.select", return_value=Answer("A LaTeX document")) 65 | mocker.patch("cradle.cli.ask", return_value=mock_context) 66 | mocker.patch("cradle.cli.copier.run_copy", return_value=None) 67 | mocker.patch("cradle.cli.setup_repository", return_value=None) 68 | cli(dst_path=None) 69 | # assert mock_run_shell_command.call_count == 6 70 | 71 | 72 | def test_append_to_yaml_file(tmp_path): 73 | """Test the append_to_yaml_file function to ensure it correctly appends data to a YAML file. 74 | 75 | Args: 76 | tmp_path (Path): Pytest fixture providing a temporary directory path. 77 | 78 | """ 79 | data = {"A": 100, "B": 200} 80 | append_to_yaml_file(file_path=tmp_path / "a.yml", new_data=data) 81 | 82 | data = {"C": 300} 83 | append_to_yaml_file(file_path=tmp_path / "a.yml", new_data=data) 84 | 85 | # Read the file and verify the content 86 | with open(tmp_path / "a.yml") as f: 87 | content = yaml.safe_load(f) 88 | 89 | # Check that all data was correctly appended 90 | expected = {"A": 100, "B": 200, "C": 300} 91 | assert content == expected 92 | 93 | 94 | def test_without_dst_path(mock_context, mocker): 95 | """Test the CLI functionality when no destination path is specified. 96 | 97 | Args: 98 | mock_context (dict): Fixture providing mock project context. 99 | mocker (pytest.MockFixture): Pytest fixture for mocking. 100 | 101 | """ 102 | mocker.patch("cradle.cli.questionary.select", return_value=Answer("A LaTeX document")) 103 | mocker.patch("cradle.cli.ask", return_value=mock_context) 104 | mocker.patch("cradle.cli.copier.run_copy", return_value=None) 105 | mocker.patch("cradle.cli.setup_repository", return_value=None) 106 | cli() 107 | # assert mock_run_shell_command.call_count == 6 108 | 109 | 110 | def test_load_defaults(resource_dir): 111 | """Test the load_defaults function with a valid file. 112 | 113 | Args: 114 | resource_dir (Path): Fixture providing the path to test resources. 115 | 116 | """ 117 | data = load_defaults(resource_dir / ".copier-answers.yml") 118 | print(data) 119 | assert data["_src_path"] == "git@github.com:tschm/experiments.git" 120 | 121 | 122 | def test_load_defaults_no_file(resource_dir): 123 | """Test the load_defaults function when the file doesn't exist. 124 | 125 | Args: 126 | resource_dir (Path): Fixture providing the path to test resources. 127 | 128 | """ 129 | data = load_defaults(file_path=resource_dir / "maffay.yml") 130 | assert data == {} 131 | 132 | 133 | # def test_load_broken(resource_dir): 134 | # """ 135 | # Test the load_defaults function with a broken YAML file. 136 | # 137 | # Args: 138 | # resource_dir (Path): Fixture providing the path to test resources. 139 | # """ 140 | # with pytest.raises(YAMLError): 141 | # load_defaults(resource_dir / "broken.yml") 142 | 143 | 144 | def test_update(tmp_path, mocker, mock_context): 145 | """Test the CLI update functionality. 146 | 147 | Args: 148 | tmp_path (Path): Pytest fixture providing a temporary directory path. 149 | mocker (pytest.MockFixture): Pytest fixture for mocking. 150 | mock_context (dict): Fixture providing mock project context. 151 | 152 | """ 153 | # copy file resource_dir /.copier-answers into temp_dir 154 | mocker.patch("cradle.cli.ask", return_value=mock_context) 155 | mocker.patch("cradle.cli.copier.run_update", return_value=None) 156 | mocker.patch("cradle.cli.setup_repository", return_value=None) 157 | cli(dst_path=tmp_path) 158 | -------------------------------------------------------------------------------- /src/tests/test_git.py: -------------------------------------------------------------------------------- 1 | """Tests for the git module.""" 2 | 3 | import subprocess 4 | from unittest.mock import MagicMock, patch 5 | 6 | import pytest 7 | from git import InvalidGitRepositoryError, Repo 8 | 9 | from cradle.utils.gh_client import GitHubCLI, is_git_repo, setup_repository 10 | 11 | 12 | # Fixtures 13 | @pytest.fixture 14 | def mock_repo(): 15 | """Pytest fixture that provides a mock Git repository. 16 | 17 | Returns: 18 | MagicMock: A mock object with the specification of a Git Repo. 19 | 20 | """ 21 | return MagicMock(spec=Repo) 22 | 23 | 24 | @pytest.fixture 25 | def github_cli(): 26 | """Pytest fixture that provides a GitHubCLI instance for testing. 27 | 28 | Returns: 29 | GitHubCLI: An instance of the GitHubCLI class with verbose mode disabled. 30 | 31 | """ 32 | return GitHubCLI(verbose=False) 33 | 34 | 35 | @pytest.fixture 36 | def sample_context(): 37 | """Pytest fixture that provides a sample context dictionary for testing. 38 | 39 | Returns: 40 | dict: A dictionary containing sample project information including username, 41 | project name, status, description, and SSH URI. 42 | 43 | """ 44 | return { 45 | "username": "testuser", 46 | "project_name": "test-repo", 47 | "status": True, 48 | "description": "Test repository", 49 | "ssh_uri": "git@github.com:testuser/test-repo.git", 50 | } 51 | 52 | 53 | # Test GitHubCLI 54 | def test_github_cli_run_success(github_cli, mocker): 55 | """Test that GitHubCLI.run successfully executes a command and returns the expected output. 56 | 57 | Args: 58 | github_cli (GitHubCLI): Fixture providing a GitHubCLI instance. 59 | mocker (pytest.MockFixture): Pytest fixture for mocking. 60 | 61 | """ 62 | mocker.patch("subprocess.run", return_value=MagicMock(stdout="success", stderr="", returncode=0)) 63 | result = github_cli.run("repo", "list") 64 | assert result == "success" 65 | 66 | 67 | def test_github_cli_run_failure(github_cli, mocker): 68 | """Test that GitHubCLI.run raises a RuntimeError when the command execution fails. 69 | 70 | Args: 71 | github_cli (GitHubCLI): Fixture providing a GitHubCLI instance. 72 | mocker (pytest.MockFixture): Pytest fixture for mocking. 73 | 74 | """ 75 | mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "gh", stderr="Error")) 76 | with pytest.raises(RuntimeError, match="GitHub CLI error"): 77 | github_cli.run("invalid", "command") 78 | 79 | 80 | def test_create_repo(github_cli, mocker): 81 | """Test that GitHubCLI.create_repo correctly calls the GitHub CLI with the expected parameters. 82 | 83 | Args: 84 | github_cli (GitHubCLI): Fixture providing a GitHubCLI instance. 85 | mocker (pytest.MockFixture): Pytest fixture for mocking. 86 | 87 | """ 88 | mocker.patch.object(github_cli, "run", return_value="Repository created") 89 | github_cli.create_repo("test/repo", private=True, description="Test") 90 | github_cli.run.assert_called_once_with( 91 | "repo", "create", "test/repo", "--private", "--description", "Test", "--confirm" 92 | ) 93 | 94 | 95 | # Test is_git_repo 96 | def test_is_git_repo_valid(tmp_path, mock_repo): 97 | """Test that is_git_repo returns True for a valid Git repository. 98 | 99 | Args: 100 | tmp_path (Path): Pytest fixture providing a temporary directory path. 101 | mock_repo (MagicMock): Fixture providing a mock Git repository. 102 | 103 | """ 104 | (tmp_path / ".git").mkdir() 105 | with patch("git.Repo", return_value=mock_repo): 106 | assert is_git_repo(tmp_path) is True 107 | 108 | 109 | def test_is_git_repo_invalid(tmp_path): 110 | """Test that is_git_repo returns False for a directory that is not a Git repository. 111 | 112 | Args: 113 | tmp_path (Path): Pytest fixture providing a temporary directory path. 114 | 115 | """ 116 | assert is_git_repo(tmp_path) is False 117 | 118 | 119 | def test_is_git_repo_error(tmp_path, mocker): 120 | """Test that is_git_repo handles InvalidGitRepositoryError correctly and returns False. 121 | 122 | Args: 123 | tmp_path (Path): Pytest fixture providing a temporary directory path. 124 | mocker (pytest.MockFixture): Pytest fixture for mocking. 125 | 126 | """ 127 | mocker.patch("git.Repo", side_effect=InvalidGitRepositoryError("Invalid")) 128 | assert is_git_repo(tmp_path) is False 129 | 130 | 131 | # # Test setup_repository 132 | # def test_setup_new_repository(tmp_path, sample_context, mocker): 133 | # # Mock external dependencies 134 | # mocker.patch('cradle.utils.gh_client.is_git_repo', return_value=False) 135 | # mock_repo = MagicMock() 136 | # mock_repo.remotes = [] 137 | # mocker.patch('git.Repo', return_value=mock_repo) 138 | # mocker.patch('git.Git.init') 139 | # mocker.patch.object(GitHubCLI, 'create_repo', return_value="Created") 140 | # 141 | # # Test 142 | # repo = setup_repository(tmp_path, sample_context) 143 | # 144 | # # Verify 145 | # GitHubCLI.create_repo.assert_called_once_with( 146 | # "testuser/test-repo", private=True, description="Test repository" 147 | # ) 148 | # mock_repo.create_remote.assert_called_once_with( 149 | # "origin", "git@github.com:testuser/test-repo.git" 150 | # ) 151 | # mock_repo.git.add.assert_called_once_with(A=True) 152 | # mock_repo.git.commit.assert_called_once_with(m="Initial commit by qcradle") 153 | # mock_repo.remotes.origin.push.assert_called_once_with(refspec="main:main") 154 | # 155 | 156 | # def test_setup_existing_repository(tmp_path, sample_context, mocker): 157 | # # Mock as existing repo 158 | # mocker.patch('cradle.utils.gh_client.is_git_repo', return_value=True) 159 | # mock_repo = MagicMock() 160 | # mock_repo.remotes = [MagicMock(name="origin")] 161 | # mocker.patch('git.Repo', return_value=mock_repo) 162 | # 163 | # # Test 164 | # repo = setup_repository(tmp_path, sample_context) 165 | # 166 | # # Verify 167 | # mock_repo.git.checkout.assert_called_once_with("main") 168 | # mock_repo.git.commit.assert_called_once_with(m="Update by qcradle") 169 | 170 | 171 | def test_setup_repository_no_gh_cli(tmp_path, sample_context, mocker): 172 | """Test that setup_repository raises a RuntimeError when GitHub CLI is not installed. 173 | 174 | Args: 175 | tmp_path (Path): Pytest fixture providing a temporary directory path. 176 | sample_context (dict): Fixture providing sample project context. 177 | mocker (pytest.MockFixture): Pytest fixture for mocking. 178 | 179 | """ 180 | mocker.patch.object(GitHubCLI, "version", return_value=False) 181 | with pytest.raises(RuntimeError, match="GitHub is not installed"): 182 | setup_repository(tmp_path, sample_context) 183 | 184 | 185 | # Test edge cases 186 | def test_repo_name_with_spaces(github_cli, mocker): 187 | """Test that GitHubCLI.create_repo correctly handles repository names with spaces. 188 | 189 | Args: 190 | github_cli (GitHubCLI): Fixture providing a GitHubCLI instance. 191 | mocker (pytest.MockFixture): Pytest fixture for mocking. 192 | 193 | """ 194 | mocker.patch.object(github_cli, "run") 195 | github_cli.create_repo("test user/repo name", description="Test") 196 | github_cli.run.assert_called_once_with( 197 | "repo", "create", "test-user/repo-name", "--public", "--description", "Test", "--confirm" 198 | ) 199 | -------------------------------------------------------------------------------- /src/tests/test_git_more.py: -------------------------------------------------------------------------------- 1 | """Tests for the git_more module.""" 2 | 3 | from pathlib import Path 4 | from unittest.mock import MagicMock, patch 5 | 6 | import git 7 | 8 | from cradle.utils.gh_client import GitHubCLI 9 | 10 | 11 | def test_git(tmp_path: Path): 12 | """Test the functionality of initializing a Git repository. 13 | 14 | Adding a file, committing changes, and verifying the commit message. 15 | 16 | Args: 17 | tmp_path (Path): Pathlib Path object that provides a temporary 18 | directory for the test. 19 | 20 | Raises: 21 | AssertionError: If the commit message does not match the expected 22 | value. 23 | 24 | """ 25 | repo_path = tmp_path / "myrepo" 26 | repo = git.Repo.init(repo_path) 27 | 28 | (repo_path / "test.txt").write_text("hello") 29 | repo.index.add(["test.txt"]) 30 | repo.index.commit("initial commit") 31 | assert repo.head.commit.message == "initial commit" 32 | 33 | 34 | @patch("cradle.utils.gh_client.safe_command.run") 35 | def test_mock(mock_run, tmp_path: Path): 36 | """Test the `create_repo` method of the `GitHubCLI` class. 37 | 38 | By mocking the external 39 | `safe_command.run` function. Verifies that the mocked function is called once 40 | with the expected behavior and confirms the expected output. 41 | 42 | Args: 43 | mock_run (MagicMock): A mock object replacing the `safe_command.run` method 44 | to observe and control its behavior. 45 | tmp_path (Path): A temporary directory path provided by pytest. 46 | 47 | Returns: 48 | None 49 | 50 | """ 51 | mock_result = MagicMock() 52 | mock_result.stdout.strip.return_value = "repo created" 53 | mock_run.return_value = mock_result 54 | 55 | cli = GitHubCLI(verbose=False) 56 | output = cli.create_repo("test-user/repo-name", description="Test") 57 | 58 | assert output == "repo created" 59 | mock_run.assert_called_once() 60 | 61 | 62 | @patch("cradle.utils.gh_client.safe_command.run") 63 | def test_mock_verbose(mock_run, tmp_path: Path): 64 | """Mock the `run` command from the `cradle.utils.gh_client.safe_command` module. 65 | 66 | Validate the functionality of the verbose mode in the `create_repo` method 67 | of the `GitHubCLI` class. 68 | 69 | Parameters 70 | ---------- 71 | mock_run : MagicMock 72 | The mock object for the `run` function. 73 | tmp_path : Path 74 | A temporary directory created for the test. 75 | 76 | Raises 77 | ------ 78 | AssertionError 79 | If the `run` command is not called exactly once or if output does not match 80 | the expected result. 81 | 82 | """ 83 | mock_result = MagicMock() 84 | mock_result.stdout.strip.return_value = "repo created" 85 | mock_run.return_value = mock_result 86 | 87 | cli = GitHubCLI(verbose=True) 88 | output = cli.create_repo("test-user/repo-name", description="Test") 89 | 90 | assert output is None 91 | mock_run.assert_called_once() 92 | -------------------------------------------------------------------------------- /src/tests/test_questions.py: -------------------------------------------------------------------------------- 1 | """Tests for the questions module.""" 2 | 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | import questionary 7 | 8 | # Import the module with your functions 9 | # Assuming the original code is in a file called project_setup.py 10 | from cradle.utils.questions import ( 11 | _validate_description, 12 | _validate_page, 13 | _validate_project_name, 14 | _validate_status, 15 | _validate_username, 16 | ask, 17 | ) 18 | 19 | 20 | def test_validate_project_name_valid(): 21 | """Test that _validate_project_name accepts valid project names. 22 | 23 | Verifies that the function returns the input name unchanged when given valid project names. 24 | """ 25 | valid_names = ["project", "project123", "project_name", "a_very_long_project_name_123"] 26 | for name in valid_names: 27 | assert _validate_project_name(name) == name 28 | 29 | 30 | def test_validate_project_name_invalid(): 31 | """Test that _validate_project_name rejects invalid project names. 32 | 33 | Verifies that the function raises a ValueError when given invalid project names, 34 | such as empty strings, names with uppercase letters, names starting with numbers, 35 | names containing spaces, or names with special characters. 36 | """ 37 | invalid_names = [ 38 | "", # empty 39 | "Project", # uppercase 40 | "123project", # starts with number 41 | "project name", # space 42 | "@project", # special character 43 | ] 44 | for name in invalid_names: 45 | with pytest.raises(ValueError): 46 | _validate_project_name(name) 47 | 48 | 49 | def test_validate_username_valid(): 50 | """Test that _validate_username accepts valid usernames. 51 | 52 | Verifies that the function returns the input username unchanged when given valid usernames. 53 | """ 54 | valid_usernames = ["user", "user123", "cvxgrp", "organization-name"] 55 | for username in valid_usernames: 56 | assert _validate_username(username) == username 57 | 58 | 59 | def test_validate_username_invalid(): 60 | """Test that _validate_username rejects invalid usernames. 61 | 62 | Verifies that the function raises a ValueError when given invalid usernames, 63 | such as empty strings or None values. 64 | """ 65 | invalid_usernames = ["", None] 66 | for username in invalid_usernames: 67 | with pytest.raises(ValueError): 68 | _validate_username(username) 69 | 70 | 71 | def test_validate_description_valid(): 72 | """Test that _validate_description accepts valid descriptions. 73 | 74 | Verifies that the function returns the input description unchanged when given valid descriptions, 75 | including short descriptions and longer descriptions with multiple words. 76 | """ 77 | valid_descriptions = ["A test project", "This is a longer description with multiple words", "Short desc"] 78 | for desc in valid_descriptions: 79 | assert _validate_description(desc) == desc 80 | 81 | 82 | def test_validate_description_invalid(): 83 | """Test that _validate_description rejects invalid descriptions. 84 | 85 | Verifies that the function raises a ValueError when given invalid descriptions, 86 | such as empty strings or None values. 87 | """ 88 | invalid_descriptions = ["", None] 89 | for desc in invalid_descriptions: 90 | with pytest.raises(ValueError): 91 | _validate_description(desc) 92 | 93 | 94 | def test_validate_status_valid(): 95 | """Test that _validate_status accepts valid status values. 96 | 97 | Verifies that the function returns the input status unchanged when given valid status values 98 | (public, private, internal). 99 | """ 100 | valid_statuses = ["public", "private", "internal"] 101 | for status in valid_statuses: 102 | assert _validate_status(status) == status 103 | 104 | 105 | def test_validate_status_invalid(): 106 | """Test that _validate_status rejects invalid status values. 107 | 108 | Verifies that the function raises a ValueError when given invalid status values, 109 | such as empty strings, None values, unrecognized status values, or status values 110 | with incorrect capitalization. 111 | """ 112 | invalid_statuses = ["", None, "invalid", "PUBLIC", "Private"] 113 | for status in invalid_statuses: 114 | with pytest.raises(ValueError): 115 | _validate_status(status) 116 | 117 | 118 | def test_validate_page_invalid(): 119 | """Test that _validate_page rejects invalid page values. 120 | 121 | Verifies that the function raises a ValueError when given invalid page values, 122 | such as None values. 123 | """ 124 | invalid_pages = [None] 125 | for page in invalid_pages: 126 | with pytest.raises(ValueError): 127 | _validate_page(page) 128 | 129 | 130 | def test_ask_integration(): 131 | """Integration test for the ask function. 132 | 133 | Tests that the ask function correctly processes user inputs and returns a properly 134 | formatted context dictionary with all expected keys and values. This test mocks 135 | the questionary.Question.ask method to simulate user inputs. 136 | """ 137 | # Mock user inputs 138 | with patch.object(questionary.Question, "ask") as mock_ask: 139 | # Configure mock to return appropriate values for different questions 140 | mock_ask.side_effect = [ 141 | "testproject", # project name 142 | "testuser", # username 143 | "Test description", # description 144 | "https://testuser.github.io/testproject", 145 | "public", # status 146 | ] 147 | 148 | result = ask() 149 | 150 | # Verify the returned context 151 | assert result["project_name"] == "testproject" 152 | assert result["username"] == "testuser" 153 | assert result["description"] == "Test description" 154 | assert result["status"] == "public" 155 | assert result["repository"] == "https://github.com/testuser/testproject" 156 | assert result["ssh_uri"] == "git@github.com:testuser/testproject.git" 157 | assert result["gh_create"] == "gh repo create testuser/testproject --public --description 'Test description'" 158 | assert result["page"] == "https://testuser.github.io/testproject" 159 | --------------------------------------------------------------------------------