pax_global_header00006660000000000000000000000064144102643460014516gustar00rootroot0000000000000052 comment=0ab51536e250d14b511481e1afaa1fc249000ba4 sphinx-theme-builder-0.2.0b2/000077500000000000000000000000001441026434600157765ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/.flake8000066400000000000000000000000701441026434600171460ustar00rootroot00000000000000[flake8] max-line-length = 88 extend-ignore = E203,E501 sphinx-theme-builder-0.2.0b2/.github/000077500000000000000000000000001441026434600173365ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/.github/ISSUE_TEMPLATE/000077500000000000000000000000001441026434600215215ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/.github/ISSUE_TEMPLATE/change.yml000066400000000000000000000021061441026434600234700ustar00rootroot00000000000000name: Trackable Change description: >- Please only use this, if you've been told to "file a trackable change issue" by a maintainer, in a discussion. body: - type: textarea attributes: label: What's happening? description: A clear and concise description of the current behaviour. validations: required: true - type: textarea attributes: label: Reproducer description: Steps to reproduce the issue. Please attach screenshots as well. value: | 1. Clone the repository at ... 2. Run `stb package` in the repository root. 3. See wrong thing. validations: required: true - type: textarea attributes: label: Expectation description: A clear and concise description of what should've happened. validations: required: true - type: checkboxes attributes: label: Code of Conduct options: - label: I agree to follow the [Code of Conduct](https://github.com/pradyunsg/furo/blob/main/CODE_OF_CONDUCT.md). required: true sphinx-theme-builder-0.2.0b2/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000017441441026434600235170ustar00rootroot00000000000000# Documentation for this file can be found at: # https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository blank_issues_enabled: false contact_links: - name: Support Request url: https://github.com/pradyunsg/sphinx-theme-builder/discussions/categories/support about: Requests for support/help may be raised as a "Support" discussion. - name: Bug Reports (and other issues) url: https://github.com/pradyunsg/sphinx-theme-builder/discussions/categories/potential-issue about: Possible bugs may be raised as a "Potential Issue" discussion. - name: Feature Request / Enhancement url: https://github.com/pradyunsg/sphinx-theme-builder/discussions/categories/ideas about: Feature requests may be raised as an "Ideas" discussion. - name: "(maintainers only) Blank issue" url: https://github.com/pradyunsg/sphinx-theme-builder/issues/new about: This link can only be used by the maintainers of this project. sphinx-theme-builder-0.2.0b2/.github/ISSUE_TEMPLATE/crash.yml000066400000000000000000000025441441026434600233510ustar00rootroot00000000000000name: Crash Report description: >- If you've gotten a `error: crash` when running Sphinx Theme Builder, use this template to report the issue. body: - type: input attributes: label: Link to source code that is involved in this issue description: >- Please provide a reference to the relevant git commit/tag/branch, or a source tarball that can be used to reproduce the crash. validations: required: true - type: textarea attributes: label: Reproducer description: >- Steps to reproduce the issue. Please provide any relevant additional context here. value: | 1. Clone the repository at ... 2. Run `stb package` in the repository root. 3. See crash. validations: required: true - type: textarea attributes: label: Output containing crash traceback description: >- Please paste the complete output of the stb command that resulted in a crash here. Note that you do NOT need to add backticks. render: console validations: required: true - type: checkboxes attributes: label: Code of Conduct options: - label: >- I agree to follow the [Code of Conduct](https://github.com/pradyunsg/sphinx-theme-builder/blob/main/CODE_OF_CONDUCT.md). required: true sphinx-theme-builder-0.2.0b2/.gitignore000066400000000000000000000001631441026434600177660ustar00rootroot00000000000000# JS stuff .nodeenv node_modules # Python stuff .nox /build /dist __pycache__ # Coverage stuff .coverage htmlcov sphinx-theme-builder-0.2.0b2/.isort.cfg000066400000000000000000000001131441026434600176700ustar00rootroot00000000000000[settings] profile = black multi_line_output = 3 known_third_party = build sphinx-theme-builder-0.2.0b2/.pre-commit-config.yaml000066400000000000000000000016111441026434600222560ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.4.1 hooks: - id: prettier - repo: https://github.com/psf/black rev: 21.9b0 hooks: - id: black language_version: python3.8 - repo: https://github.com/asottile/blacken-docs rev: v1.11.0 hooks: - id: blacken-docs additional_dependencies: [black==21.9b0] - repo: https://github.com/PyCQA/isort rev: 5.9.3 hooks: - id: isort files: \.py$ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.0.1 hooks: - id: check-builtin-literals - id: check-case-conflict - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: forbid-new-submodules - id: trailing-whitespace - repo: https://gitlab.com/pycqa/flake8 rev: 3.9.2 hooks: - id: flake8 sphinx-theme-builder-0.2.0b2/.prettierrc.toml000066400000000000000000000000451441026434600211330ustar00rootroot00000000000000useTabs = false proseWrap = "always" sphinx-theme-builder-0.2.0b2/LICENSE000066400000000000000000000020701441026434600170020ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2021 Pradyun Gedam Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sphinx-theme-builder-0.2.0b2/README.md000066400000000000000000000021111441026434600172500ustar00rootroot00000000000000# Sphinx Theme Builder Streamline the Sphinx theme development workflow, by building upon existing standardised tools. - simplified packaging experience - simplified JavaScript tooling setup - development server, with rebuild-on-save and automagical browser reloading - consistent repository structure across themes ## Installation This project is available on [PyPI](https://pypi.org/project/sphinx-theme-builder/), and can be installed using `pip`: ``` pip install "sphinx-theme-builder[cli]" ``` This project requires modern versions of CPython (>= 3.7). ## Usage Find more details on how to use this project in the [documentation]. ## Contributing `stb` is a volunteer maintained open source project, and we welcome contributions of all forms. Please take a look at the [Development Documentation] for more information. [documentation]: https://sphinx-theme-builder.rtfd.io/ [development documentation]: https://sphinx-theme-builder.rtfd.io/en/latest/development/ sphinx-theme-builder-0.2.0b2/docs/000077500000000000000000000000001441026434600167265ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/docs/build-process.md000066400000000000000000000116761441026434600220360ustar00rootroot00000000000000# Build Process This document describes the build process of a theme, built using Sphinx Theme Builder (`stb`). In other words, this document serves as an elaborate answer to the question: "What happens when I run `stb compile` or `stb package`?" (asset-generation)= ## Asset Generation ### nodeenv creation `stb` invokes {pypi}`nodeenv`, in a subprocess and creates an isolated installation of NodeJS in a `.nodeenv` directory. If a `.nodeenv` directory already exists, this step is skipped. ### nodeenv validation Once `stb` has a nodeenv available, it will run `node --version` using the nodeenv's `node` and validate that it matches the requirements of the theme. This serves as a validation check to ensure that the user does not incorrectly use a broken nodeenv (in which case, `node` will fail to run) or a NodeJS version that is incompatible with the theme (which can happen in some situations). Even when the nodeenv was just created, this serves as a sanity-check that the nodeenv that has been created is indeed functional and valid. ### `npm install` Once the nodeenv is created, the JS dependencies of the theme are installed by running `npm install` using from the nodeenv. This will create a `node_modules` directory containing these dependencies. If a `node_modules` directory already exists and is newer than the `package.json` file, this step is skipped. ### `npm run-script build` With all the NodeJS dependencies of the theme installed, the NodeJS-based build is performed by `npm` from the nodeenv. This build is done by using `npm run-script build` which looks at the `"build"` key in the `"scripts"` section of `package.json`. For a package that uses [Webpack] for performing their JS build, this would mean that the `package.json` would look something like: ```json { "devDependencies": { "webpack": "...", "webpack-cli": "..." }, "scripts": { "build": "webpack" } } ``` This command is expected to generate the compiled assets for the theme. If the theme's `build` command is being executed, everything that `stb` does is working correctly. [webpack]: https://webpack.js.org/ ## Python Packaging stuff `stb package` dispatches the task of performing the build to the {pypi}`build` project, by invoking it in a subprocess. The build project orchestrates the details of the build process and ends up invoking `stb`'s build logic, in the appropriate manner. The build project generates a source distribution, unpacks it and builds a wheel from the unpacked source distribution. ## Source Distribution For generating the source distribution, `stb` will generate a tarball that contains files from the `src/` directory of the project. Certain files are not included in this tarball, based on the following rules: - Exclude hidden files. - Exclude `.pyc` files. - Exclude compiled assets. - Exclude files that are excluded from version control (only git is supported). ## Wheel Distribution For generating the wheel distribution, `stb` will generate the theme's assets in production mode (using the {ref}`asset-generation` process described above). Once this is generated, `stb` will generate a wheel file containing the package metadata and all the files in the `src/` directory. ```{caution} It is expected that wheels would only be generated from unpacked source distributions. Attempting to generate a wheel from the source tree directly may result in incorrect contents in the wheel. ``` (controlling-nodejs)= ## Appendix: Controlling NodeJS installation {pypi}`nodeenv` will prefer to use pre-built binaries, if they're available for the platform that the build is taking place on. If a pre-built binary is not available, it tries to build NodeJS from source on the machine. By default, the pre-built binaries are fetched from: - for supported platforms - for musl-based platforms It is possible to configure the behaviour of `nodeenv` using [a `~/.nodeenvrc` file][nodeenv-configuration] to change its behaviour, such as whether it uses a pre-built binary or what mirror it downloads from. [nodeenv-configuration]: https://github.com/ekalinin/nodeenv#configuration ### `STB_USE_SYSTEM_NODE` When set to `true` or `1`, `stb` will ask {pypi}`nodeenv` to use the `node` executable available on `PATH`, for creating the nodeenv. This functionality is primarily for software redistributors who have policies against using prebuilt binaries from the NodeJS team, such as the ones that `nodeenv` tries to use by default. ### `STB_RELAX_NODE_VERSION_CHECK` When set to `true` or `1`, `stb` will _not_ enforce that the NodeJS version in the `.nodeenv` directory exactly match the declared version in the theme. Instead, the check changes to ensuring that it has the same major version and an equal-or-higher minor version. The patch version is ignored. This functionality is primarily for software redistributors who wish to use newer-but-still-compatible versions of the NodeJS. sphinx-theme-builder-0.2.0b2/docs/changelog.md000066400000000000000000000057221441026434600212050ustar00rootroot00000000000000# Changelog ## 0.2.0b2 - Adopt the newer copy of `copyfileobj_with_hashing` - Correctly encode `RECORD` hashes - Document a previously undocumented error case - Document the `--pdb` flag - Improve documentation to pass nit-picks - Improve the `autobuild-failed` documentation - Use tomllib on Python 3.11+ ## 0.2.0b1 - Add `--host` to `stb serve`. - Document a theme asset management approach. - Fix the generator value. - Generate a `package-lock.json` file, if it does not exist. - Switch to `pyproject-metadata` (from `pep621`). ## 0.2.0a15 - Add `--pdb` flag to `stb serve`. - Accept more values for `STB_USE_SYSTEM_NODE`, error out on invalid ones. - Add `STB_RELAX_NODE_VERSION_CHECK`. - Fix typing-related import for Python 3.7 compatibility. - Document all errors in the error index, describing what the user can do. - Fix project source URL in metadata. - Improve the getting started tutorial. - Tweak how links are presented in errors. ## 0.2.0a14 - Don't pin the upper Python version. - Present the traceback on Sphinx failures. - Update error message for `nodeenv-creation-failed` - Quote the `sys.executable`. - Fix mis-formatted README opening. - Back to development. ## 0.2.0a13 - Simplify system node usage logic. - Use the correct binary directory on Windows. - Reducing the size of the generated nodeenv. - Add TODOs to the tutorial, to reflect it is incomplete. ## 0.2.0a12 - Fix Windows compatibility. ## 0.2.0a11 - Fix Python 3.7 compatibility. - Fix handling of missing `node` executable on system. - Explicitly declare the LICENSE. ## 0.2.0a10 - Fix improper RECORD file generation. ## 0.2.0a9 - Try to fix improper RECORD file generation. ## 0.2.0a8 - Add `stb compile --production` - Improve documentation on what the project layout is. ## 0.2.0a7 - Allow setting alternative theme name. - Enable users to specify custom "additional compiled static assets". - Present error when npm is not found. - Present more context when deciding on using `system` nodeenv. - Run `nodeenv` with rich traceback installed. - Search `PATH` for executables to run in nodenv. - Suppress exception stack from click. ## 0.2.0a6 - Include parent paths of compiled files, when computing files for the wheel archive. - Fix release version management. ## 0.2.0a5 - Include setuptools as a dependency. ## 0.2.0a4 - Add `stb npm` command, to make it easier to run npm within the nodeenv. - Properly handle `nodeenv` and CLI colours. - Get `node-version` from project configuration. - Use the `node` from PATH, if it matches the required version - Handle aborts coming out of click. - Handle unclean exits in `build`. ## 0.2.0a3 - Improve `stb serve`. - Improve handling and presentation of errors from `main`. - Run project structure validation in more situations. - Consolidate compiled asset calculation. - Add a direct dependency on `nodeenv`. ## 0.2.0a2 - Update the paths that source assets are stored in. - Correctly handle `[project]` in the error output. ## 0.2.0a1 Initial release. sphinx-theme-builder-0.2.0b2/docs/cli/000077500000000000000000000000001441026434600174755ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/docs/cli/compile.md000066400000000000000000000002541441026434600214500ustar00rootroot00000000000000# `stb compile` This command compiles the current project's assets. ## Options ### `--production` Runs the build with `NODE_ENV=production` (`development` by default). sphinx-theme-builder-0.2.0b2/docs/cli/index.md000066400000000000000000000001251441026434600211240ustar00rootroot00000000000000# CLI Detailed information to the various CLI commands. ```{toctree} :glob: * ``` sphinx-theme-builder-0.2.0b2/docs/cli/new.md000066400000000000000000000005541441026434600206140ustar00rootroot00000000000000# `stb new` This command uses {pypi}`cookiecutter` under the hood, with a dedicated template, to help set up a new project. This is based on `cookiecutter` which means it is also possible for theme authors to use tooling built for it -- like {pypi}`cruft`, to keep their theme up to date with the underlying template, as the template evolves. ## Options None. sphinx-theme-builder-0.2.0b2/docs/cli/npm.md000066400000000000000000000003301441026434600206050ustar00rootroot00000000000000# `stb npm` Interact with the npm, available in the environment. This command requires the nodeenv to exist. Typically, you can create it by running the {doc}`compile` command successfully once. ## Options None. sphinx-theme-builder-0.2.0b2/docs/cli/package.md000066400000000000000000000002221441026434600214060ustar00rootroot00000000000000# `stb package` Generate Python distribution files. (sdist and wheel) This is done by running {pypi}`build` in a subprocess. ## Options None. sphinx-theme-builder-0.2.0b2/docs/cli/serve.md000066400000000000000000000020311441026434600211370ustar00rootroot00000000000000# `stb serve` Serve the provided documentation path, with livereload on changes. ## Usage This start a long-running server with live-reload that watches for changes in the theme or documentation sources (using {pypi}`sphinx-autobuild`). When a change is made, it will rebuild the assets of the theme, rebuild the documentation using Sphinx and reload any open browser tabs that are viewing an HTML page served by the server. ## Options ### `--builder` The Sphinx builder to build the documentation with. Allowed values: `html` (default), `dirhtml` ### `--host` hostname to serve documentation on (default: 127.0.0.1) ### `--port` The port to start the server on. Uses a random free port by default. Allowed values: INTEGER ### `--pdb` Run pdb if the Sphinx build fails with an exception. ### `--open-browser / --no-open-browser` Open the browser after starting live-reload server. This is done by default. ### `--override-theme / --no-override-theme` Override the `html_theme` value set in `conf.py`. This is not done by default. sphinx-theme-builder-0.2.0b2/docs/conf.py000066400000000000000000000030271441026434600202270ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # Full list of options can be found in the Sphinx documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # # -- Project information ----------------------------------------------------- # project = "sphinx-theme-builder" copyright = "2021, Pradyun Gedam" author = "Pradyun Gedam" # # -- General configuration --------------------------------------------------- # extensions = [ # Sphinx's own extensions "sphinx.ext.autodoc", "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx.ext.mathjax", "sphinx.ext.todo", "sphinx.ext.viewcode", # External stuff "myst_parser", "sphinx_copybutton", "sphinx_inline_tabs", ] templates_path = ["_templates"] # # -- Options for extlinks ---------------------------------------------------- # extlinks = { "pypi": ("https://pypi.org/project/%s/", "%s"), } # # -- Options for intersphinx ------------------------------------------------- # intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "sphinx": ("https://www.sphinx-doc.org/en/master", None), } # # -- Options for TODOs ------------------------------------------------------- # todo_include_todos = True # # -- Options for Markdown files ---------------------------------------------- # myst_enable_extensions = [ "deflist", ] myst_heading_anchors = 3 # # -- Options for HTML output ------------------------------------------------- # html_theme = "furo" html_title = "Sphinx Theme Builder" sphinx-theme-builder-0.2.0b2/docs/development/000077500000000000000000000000001441026434600212505ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/docs/development/index.md000066400000000000000000000000551441026434600227010ustar00rootroot00000000000000# Development ```{TODO} Flesh this out. ``` sphinx-theme-builder-0.2.0b2/docs/errors.md000066400000000000000000000401011441026434600205600ustar00rootroot00000000000000# Error Index Inspired by the [Rust Compiler Error Index], this page describes the various errors that may be presented by `sphinx-theme-builder`, indicating known causes as well as potential solutions. [rust compiler error index]: https://doc.rust-lang.org/error-index.html ## crash There is an unexpected exception raised within `sphinx-theme-builder`. This is usually a symptom of an incorrect assumption/expectation or a bug in the implementation of `sphinx-theme-builder`. **What you can do:** It is recommended to report this issue as a crash report, on the [issue tracker]. This error will print a detailed traceback above it, which should be included. [issue tracker]: https://github.com/pradyunsg/sphinx-theme-builder/issues ## nodeenv-creation-failed A NodeJS environment (nodeenv) could not be created, for some reason. Typically, the reason for the failure is indicated by the output above the error. This is an error from the underlying tooling that `sphinx-theme-builder` uses. A `urllib.error.HTTPError` indicates that the issue is related to the network or the availability of NodeJS release files. It may mean the node version that this tool is trying to fetch is no longer available, for example if there is no compatible NodeJS binary for the operating system. **What you can do:** When this error is encountered, a good place to look at is the {pypi}`nodeenv` project's documentation and issue tracker. ## non-boolean-env-variable-value This error is raised when the user sets the a boolean environment variable to an invalid value. The valid values for boolean environment variables are `true`, `false`, `1` and `0`. They are case-insensitive. Providing any other value will cause this error to be raised. **What you can do:** Provide a valid value, for the environment variable involved. ## can-not-use-system-node-on-windows This error is raised when the user tries to use the system NodeJS installation on Windows. The underlying tooling that Sphinx Theme Builder uses ({pypi}`nodeenv`) does not support using an existing system NodeJS installation for creating the nodeenv, so this is not permitted. **What you can do:** Unset the `STB_USE_SYSTEM_NODE` environment variable, since it is not possible to use the system NodeJS installation on Windows. ## js-build-failed This error indicates that the build step using the Javascript tooling failed, which is a theme-specific issue. Typically, there is a JS error reported by the theme-specific tooling immediately above this error. Sphinx Theme Builder executes `npm run-script build` for running the Javascript tooling. When that fails, this error is raised which means that something went wrong in the Javascript build pipeline of the theme that is being built/packaged. **What you can do:** Look into the JS error reported, since that error is what needs to be looked at (and possibly, put into a search engine). ## js-install-failed This error indicates that the install step for the Javascript tooling failed, which is a theme-specific issue. Typically, there is a JS error reported by the theme-specific tooling immediately above this error. Sphinx Theme Builder executes `npm install` for installing the Javascript tooling that the theme declares a dependency on. When that fails, this error is raised which means that something went wrong in the installation of the JS dependencies of the theme that is being built/packaged. **What you can do:** Look into the JS error reported, since that error is what needs to be looked at (and possibly, put into a search engine). ## nodeenv-unhealthy-npm-not-found This error indicates that the nodeenv created for building this theme is broken, and does not have an `npm` executable. Typically, this is a symptom of an incomplete cleanup. **What you can do:** Deleting the `.nodeenv` directory and trying again will usually resolve this issue. If this happens while performing multiple builds of the same theme in parallel (which is not supported at this time), this is likely caused by a race condition due to the lack of support for this mode of usage. You'll need to ensure that no parallel builds are occurring in the same directory. ## nodeenv-unhealthy-file-not-found This error indicates that the nodeenv created for building this theme is broken, and does not contain a file that should have been there (the `node` executable). Typically, this is a symptom of an incomplete cleanup. **What you can do:** Deleting the `.nodeenv` directory and trying again will usually resolve this issue. If this happens while performing multiple builds of the same theme in parallel (which is not supported at this time), this is likely caused by a race condition due to the lack of support for this mode of usage. You'll need to ensure that no parallel builds are occurring in the same directory. ## nodeenv-unhealthy-subprocess-failure This error indicates that the nodeenv created for building this theme is broken, and can not be used. Sphinx Theme Builder executes `node --version` using the NodeJS available in the nodeenv to determine what version is available in the nodeenv. This error indicates that this subprocess failed, which should never happen for a valid and functional nodeenv. **What you can do:** This is not a typical failure and may be a symptom of a different underlying issue (incompatible architecture, broken OS libraries etc). It may be possible to work around this by deleting the `.nodeenv` directory and trying again. ## nodeenv-version-mismatch This error indicates that the nodeenv created for building this theme does not match the declared requirements of the theme, and can not be used. Typically, this is because the declared requirement of the theme has been changed. **What you can do:** Deleting the `.nodeenv` directory and trying again will usually resolve this issue. If it does not, please see {ref}`controlling-nodejs`. If you're a redistributor of software (rather than a user or theme author), the aforementioned link should provide relevant information. ## unable-to-cleanup-node-modules This error indicates that the `node_modules` folder could not be deleted. **What you can do:** This is not a typical failure and will need diagnosis of why the deletion might have failed (eg: permission issues, filesystem issues). ## invalid-pyproject-toml This error indicates that the theme does not have a valid `pyproject.toml` file in the root, due to the contents of the file. Typically, this is related to the the `project` table and mentions the problematic key. **What you can do:** Resolve the issue as suggested by the error message. You can find more details by looking up documentation about the `[project]` table (eg: {pep}`621`). ## pyproject-missing This error indicates that theme does not have a `pyproject.toml` file in the root directory of the theme's package (typically, the repository root). It is required for using Sphinx Theme Builder. **What you can do:** Create a `pyproject.toml` file and try again. You'll get a new error, which will help guide you forward. ## pyproject-could-not-parse This error indicates that the theme do not have a valid `pyproject.toml` file. It needs to be a valid TOML 1.0.0 file. **What you can do:** Investigate why the file is not a valid TOML file and fix the issue. ## pyproject-no-project-table This error indicates that the `pyproject.toml` file in the theme does not contain the `[project]` table. That table provides required metadata and must be specified. **What you can do:** Declare your project's metadata in the `[project]` table. See {pep}`621` for more details on the syntax. ## pyproject-no-name-in-project-table This error indicates that the `pyproject.toml` file in the theme does not contain the required `name` key in the `[project]` table. **What you can do:** Declare your project's name in the `[project]` table. ## pyproject-non-canonical-name This error indicates that the `pyproject.toml` file in the theme has a `name` key that is not formatted in a canonical format. This needs to be a `dashed-name-with-only-lowercase-characters`. **What you can do:** Fix your project's `name` in the `[project]` table. ## pyproject-invalid-version This error indicates that the `pyproject.toml` file in the theme has a `version` key that is not a string. **What you can do:** Fix your project's `version` in the `[project]` table. ## project-init-missing This error indicates that the theme's `__init__.py` file could not be found. **What you can do:** Move/create your theme's importable package in the location expected. See {doc}`filesystem-layout` for more details. ## project-init-invalid-syntax This error indicates that the theme's `__init__.py` file is not a valid Python file. This file is parsed by Sphinx Theme Builder, to look up the Python version of that file. **What you can do:** Fix the file to be valid Python code. ## project-double-version-declaration This error indicates that the theme has declared its version in both `__init__.py` file and the `pyproject.toml` file. This is not supported. **What you can do:** Declare your theme's version in only one location. Typically, you can do this by removing the version declaration in the `pyproject.toml` file. ## project-missing-dynamic-version This error indicates that the theme's `__init__.py` file does not contain a version declaration in the form expected by Sphinx Theme Builder. This needs to be a top-level assignment of the form `__version__ = `. **What you can do:** Declare your theme's version in the form expected. ## project-improper-dynamic-version This error indicates that the `pyproject.toml` file in the theme has a `version` key _and_ declares that the version will be picked up from the `__init__.py` file (by including version in the `dynamic` key). **What you can do:** Declare your theme's version in the form expected. ## project-no-version-declaration This error indicates that Sphinx Theme Builder could not locate a version declaration for this theme. Typically, this is because the version has not been declared in either of the `pyproject.toml` or `__init__.py` files. **What you can do:** Declare your theme's version in the `pyproject.toml` file or in the theme's "base" `__init__.py` file. ## project-invalid-version This error indicates that the theme's declared version is not a valid Python Package version (see {pep}`440` for the specification). **What you can do:** Fix your theme's version. ## no-license-declared This error indicates that the `pyproject.toml` file does not declare a license, which is not permitted when using Sphinx Theme Builder. **What you can do:** Declare a license. ## unsupported-dynamic-keys This error indicates that there are unsupported values the `dynamic` key in `pyproject.toml` file. **What you can do:** Remove the unsupported values. ## no-node-version This error indicates that no NodeJS version was configured in the `pyproject.toml` file. **What you can do:** Configure the NodeJS version in the theme's `pyproject.toml` file. ## node-version-mismatch This error indicates that the NodeJS version in the nodeenv does not match what was configured in the `pyproject.toml` file. **What you can do:** Typically, this is only seen when {ref}`the default NodeJS handling is overridden` and is a symptom of misconfiguration (either of sphinx-theme-builder or within the environment). Ensure that a matching NodeJS version for the project being build is available and, if so, delete the `.nodeenv` directory and try again. If this happens while performing multiple builds of the same theme in parallel (which is not supported at this time), this is likely caused by a race condition due to the lack of support for this mode of usage. You'll need to ensure that no parallel builds are occurring in the same directory. ## missing-theme-conf This error indicates that the `theme.conf` file for the theme could not be located. This is typically a genuinely missing file, a symptom of a misconfiguration or incorrect filesystem layout of the package. The misconfiguration is typically forgetting to set `theme-name` in `[tool.sphinx-theme-builder]` in pyproject.toml, when it does not match the PyPI package name. **What you can do:** Ensure that your theme name is correct and, if so, move/create the `theme.conf` file, in the expected location. If you're creating this file, you can find more details in [Sphinx's theme creation documentation](https://www.sphinx-doc.org/en/master/development/theming.html#creating-themes). ## missing-javascript This error indicates that the Javascript file for the theme could not be located. This is typically a genuinely missing file, a symptom of a misconfiguration or incorrect filesystem layout of the package. The misconfiguration is typically forgetting to set `theme-name` in `[tool.sphinx-theme-builder]` in pyproject.toml, when it does not match the PyPI package name. **What you can do:** Ensure that your theme name is correct and, if so, ensure that the Javascript file is available, in the expected location. ## missing-stylesheet This error indicates that the CSS file for the theme could not be located. This is typically a genuinely missing file, a symptom of a misconfiguration or incorrect filesystem layout of the package. The misconfiguration is typically forgetting to set `theme-name` in `[tool.sphinx-theme-builder]` in pyproject.toml, when it does not match the PyPI package name. **What you can do:** Ensure that your theme name is correct and, if so, ensure that the CSS file is available in the expected location. ## could-not-read-theme-conf This error indicates that `theme.conf` could not be read. Sphinx Theme Builder tries to read the `theme.conf` file of the theme being built, parse it and read certain configuration variables declared in it. This error is presented when that operation fails. More details are typically available from the "context" information provided in the error message (the line after "Could not open/parse). **What you can do:** Ensure that this file can be pasted as an INI file. ## theme-conf-incorrect-stylesheet This error indicates that the style file declared in `theme.conf` does not match the stylesheet that was expected based on the theme name. Sphinx Theme Builder enforces this consistency to make it easier to avoid collisions within derived themes and ensure that CSS file for the theme could not be located. **What you can do:** Update the filename of the CSS file in `theme.conf`, to match what is expected. You may also need to update your build system to generate the file in the corresponding location. ## missing-command-line-dependencies This error indicates that one or more of the dependencies that are needed to use the `stb` command are not installed. This is typically a symptom of doing `pip install sphinx-theme-builder` instead of `pip install "sphinx-theme-builder[cli]"`. **What you can do:** Run `pip install "sphinx-theme-builder[cli]"` to install the missing dependencies. ## can-not-overwrite-existing-python-project This error is raised to prevent users from overwriting existing Python projects with `stb new`, since that command does not attempt to preserve any file and is not designed to be used on an existing Python project. **What you can do:** Use a clean directory for running `stb new`. ## cookiecutter-failed This error indicates that `stb new` could not create the repository. This is currently expected as the repository that `stb new` tries to use is not set up. **What you can do:** Manually create your theme's codebase, using existing themes like [Furo] or [pydata-sphinx-theme] as the base for that. [furo]: https://github.com/pradyunsg/furo/ [pydata-sphinx-theme]: https://github.com/pydata/pydata-sphinx-theme/ ## no-nodeenv This error is raised by `stb npm` when the user tries to use it without creating the nodeenv for the project. **What you can do:** Run `stb compile` once, to create the nodeenv. ## autobuild-failed This error is raised when `sphinx-autobuild` exits with an error, which is typically caused by a fatal error or interrupt. **What you can do:** Investigate why the Sphinx build failed. Typically, the output for failure above this error will provide relevant information. sphinx-theme-builder-0.2.0b2/docs/filesystem-layout.md000066400000000000000000000056571441026434600227640ustar00rootroot00000000000000# Filesystem Layout Sphinx Theme Builder requires the themes to follow a fairly specific project layout and structure. This standard structure is what allows this tool to provide a sensible build pipeline, which in-turn enables the nice quality-of-life things like `stb serve`. ## How it looks ```yaml - .gitignore # The theme should be under version control with git - README.md - LICENSE - package.json # For Javascript-based build tooling. - pyproject.toml # For Python package metadata and tooling. - src: - my_amazing_theme: # The importable Python package (notice underscores) - __init__.py - [other Python files] - theme: # HTML templates - my-amazing-theme: - [various .html pages] - static - [any static assets that don't need to be compiled, like images] - assets: # Static assets, SASS and JS. - [static assets that need to be compiled, possibly within folders] - styles: - index.sass # Compiled into the final CSS file. - [other Sass / SCSS files] - scripts: - my-amazing-theme.js # Compiled into the final JS file. - [other JS files] ``` ## Need for version control Sphinx Theme Builder does not enforce the use of Git but, as part of the build process, it will exclude any files that are excluded from Git's tracking using any of the supported mechanisms (typically, a `.gitignore` file in the repository root). This information is queried as part of the source distribution generation process. ## Auto-generated folders The following folders will be auto-generated when the theme's assets are compiled. Add them to the project's `.gitignore`: - `.nodeenv` - The NodeJS (+ `npm`) installation that is used to compile the theme's assets. - `node_modules` - The NodeJS packages that are installed for use, to compile the theme's assets. - `src/theme//static/styles` - The compiled CSS assets for the theme - `src/theme//static/scripts` - The compiled JS assets for the theme ## How to get this right Nearly everything about the filesystem layout and the contents of the various configuration files are validated, as part of the build process. This means that you can get the layout and contents of the files correct by ensuring that the build of the theme succeeds, when using Sphinx Theme Builder. This typical workflow for doing this looks something like: 1. Update the `build-backend` for the theme to Sphinx Theme Builder. ```{code-block} toml :name: stb-pyproject-config :caption: pyproject.toml [build-system] requires = ["sphinx-theme-builder >= 0.2.0a14"] build-backend = "sphinx_theme_builder" ``` 1. Run `stb package` in the same directory as the `pyproject.toml` file. 1. Fix the error presented. 1. Repeat the above two steps, that until the `stb package` command succeeds. 1. And.. done! sphinx-theme-builder-0.2.0b2/docs/index.md000066400000000000000000000007411441026434600203610ustar00rootroot00000000000000--- hide-toc: true --- # Sphinx Theme Builder ```{include} ../README.md :start-after: :end-before: ``` ```{toctree} :hidden: tutorial build-process filesystem-layout theme-assets cli/index errors ``` ```{toctree} :hidden: :caption: Project development/index changelog license GitHub ``` sphinx-theme-builder-0.2.0b2/docs/license.md000066400000000000000000000000611441026434600206670ustar00rootroot00000000000000# License ```{include} ../LICENSE :literal: ``` sphinx-theme-builder-0.2.0b2/docs/requirements.txt000066400000000000000000000000751441026434600222140ustar00rootroot00000000000000sphinx furo myst-parser sphinx-copybutton sphinx-inline-tabs sphinx-theme-builder-0.2.0b2/docs/theme-assets.md000066400000000000000000000046401441026434600216560ustar00rootroot00000000000000# Managing theme assets The `sphinx-theme-builder` intentionally separates asset source files from the compiled assets that are bundled with your theme. These generally fall into two folders: - "Asset sources" in `src/my_awesome_theme/assets` The source files used for generating the theme's actual stylesheet/JavaScript files (e.g. `.scss` files, `.ts` files). - "Compiled static assets" in `src/my_awesome_theme/theme/my-awesome-theme/static` These are the files that will be used by Sphinx (e.g. `.css` files generated from `.scss`, `.js` files after transpiling and bundling). ## Compiling assets As described in the {doc}`build-process` document, themes are expected to compile assets through a NodeJS-based build system with `npm run-script build` being the entrypoint for it. During theme development, it is expected that theme authors will not need to invoke npm manually. Instead, when you want to compile the theme's assets, you'll run: ```sh $ stb compile ``` This will handle all the details of fetching NodeJS, the npm dependencies and other running the build via npm. When previewing documentation using `stb serve`, this command will run prior to rebuilding the documentation. This means that your theme development workflow will not require manually invoking the rebuild. The live-reloading server ignores assets in the static folder, so generating files there does not trigger a re-build loop. ## Example: Webpack-based asset generation For an example, we'll use Furo's 2022.06.21 release. That version of the Furo theme uses a Webpack based asset generation pipeline, for compiling Sass and JS source assets into generated CSS and JS assets. The relevant files for this setup are: - - Configures Sphinx Theme Builder - Declares Python metadata + dependencies - Notice `additional-compiled-static-assets` in there - - Declares JS build dependencies - Declares entrypoint for `npm run-script build` as `webpack` - - Configures Webpack to compile the assets into the relevant location - transpile Sass files into CSS and run PostCSS on it - bundle multiple JS files into a single JS file - - Configure PostCSS to run autoprefixer sphinx-theme-builder-0.2.0b2/docs/tutorial.md000066400000000000000000000075511441026434600211230ustar00rootroot00000000000000# Getting Started Sphinx Theme Builder provides a streamlined workflow for developing Sphinx themes. This tutorial walks through the process of setting up a new Sphinx theme, making changes to it and generating PyPI distribution files for it. This tutorial expects that the reader has working knowledge of: - Terminal / Command Prompt - Python virtual environments - Web technologies (HTML/JS/CSS) - Git / GitHub [sphinx]: https://www.sphinx-doc.org/en/master/ [jinja]: https://palletsprojects.com/p/jinja/ ## Installation As a first step, let's install this tool with the `cli` extra, in a clean virtual environment: ```shell $ pip install "sphinx-theme-builder[cli]" ``` ## Create a new theme To create a new theme, you can use the `stb new` command. ```shell $ stb new my-awesome-sphinx-theme ``` ```{todo} Actually write the template that this uses. ``` You will be prompted to answer a few questions. Once they've been answered, this command will generate a scaffold for your theme in a folder named `my-awesome-sphinx-theme`. For the rest of this tutorial, we're going to exclusively work in this directory, so it's sensible to `cd` into it. ```shell $ cd my-awesome-sphinx-theme ``` [cookiecutter]: https://cookiecutter.readthedocs.io/ [cruft]: https://github.com/cruft/cruft ## Install the theme To work with your theme, it is necessary to install it in the virtual environment. Let's do an editable install, since that's usually what you would want to do for development. ```shell $ pip install -e . ``` Note: an editable install with sphinx-theme-builder as backend requires a modern version of pip (>= 21.3) ## Start the development server To start a development server, you use the `stb serve` command. It needs a path to a directory containing Sphinx documentation, so we'll use the demo documentation that comes as part of the default scaffold: ```shell $ stb serve docs/ ``` This command will do a few things (we'll get to details of this later) and, after a short delay, opens your default browser to view the built documentation. Keep this terminal open/running. The development server simplifies the workflow for seeing how a change affects the generated documentation fairly straightforward -- save changes to a file, switch to the browser and the browser will update to reflect those changes. ## Making changes ```{todo} This requires that the template in `stb new` works, and uses `sphinx-basic-ng`. ``` To try out how the development server handles changes, create a new `sections/article.html` file in the `src/{your_package_name}/theme/{your_theme_name}` with the following content: ```jinja {{ content }} ``` The server should do a few things and the page will automagically reload with the new page contents. ### How it works The development server listens for changes in your theme or the documentation (i.e. when a file is saved/renamed/moved). When it detects that a change has been made, the server will: 1. Recompile your theme's assets 2. Rebuild the Sphinx documentation it is serving 3. Automagically reload open browser tabs of HTML pages served by it If the theme's asset compilation or the documentation build (with Sphinx) fails, the server will print something in the terminal window about the failure. ## Stopping the server To stop the server, focus on the terminal where the server is running and press {kbd}`Ctrl`+{kbd}`C`. ## Packaging the theme When you wish to publish your theme on PyPI, you will need to package your theme into a few distribution files and upload these files to PyPI. This makes it possible to install the package for your theme, which can be downloaded and installed with `pip`. To generate the distribution files, run: ```shell $ stb package ``` This will generate files in `dist/`, that contain the relevant distribution files for your project. These can be uploaded to PyPI using {pypi}`twine`. ## Next Steps Go write a Sphinx theme! sphinx-theme-builder-0.2.0b2/noxfile.py000066400000000000000000000110021441026434600200060ustar00rootroot00000000000000"""Development automation.""" import glob import os import shutil import tempfile import nox PACKAGE_NAME = "sphinx_theme_builder" nox.options.sessions = ["lint", "docs"] # # Development Sessions # @nox.session(name="docs-live", reuse_venv=True) def docs_live(session): session.install("-e", ".[cli]") session.install("-r", "docs/requirements.txt") session.install("sphinx-autobuild") with tempfile.TemporaryDirectory() as destination: session.run( "sphinx-autobuild", # for sphinx-autobuild "--watch=README.md", "--port=0", "--open-browser", # for sphinx "-b=dirhtml", "-a", "docs/", destination, ) @nox.session(reuse_venv=True) def docs(session): session.install("-e", ".[cli]") session.install("-r", "docs/requirements.txt") # Generate documentation into `build/docs` session.run("sphinx-build", "-b", "dirhtml", "-W", "--keep-going", "-v", "docs/", "build/docs",) @nox.session(reuse_venv=True) def lint(session: nox.Session): session.install("pre-commit", "mypy") args = list(session.posargs) args.append("--all-files") if "CI" in os.environ: args.append("--show-diff-on-failure") session.run("pre-commit", "run", *args) session.notify("mypy") @nox.session def mypy(session: nox.Session): session.install(".[cli]", "mypy", "-r", "tests/requirements.txt") session.run("mypy", "src", "tests", "--strict") @nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11"], reuse_venv=True) def test(session): if os.environ.get("CI"): session.install(".[cli]") else: session.install("-e", ".[cli]") session.install("-r", "tests/requirements.txt") args = session.posargs or ( "--cov=sphinx_theme_builder", "--cov-branch", "--cov-report=term-missing", "--cov-report=html", ) session.run( "pytest", "--pspec", *args, ) def get_release_versions(version_file): marker = "__version__ = " with open(version_file) as f: for line in f: if line.startswith(marker): version = line[len(marker) + 1 : -2] current_version, current_dev_number = version.split("dev") break else: raise RuntimeError("Could not find current version.") next_dev_number = int(current_dev_number) + 1 release_version = f"{current_version}b{next_dev_number}" next_version = f"{current_version}dev{next_dev_number}" return release_version, next_version @nox.session def release(session): version_file = f"src/{PACKAGE_NAME}/__init__.py" allowed_upstreams = [ f"git@github.com:pradyunsg/{PACKAGE_NAME.replace('_', '-')}.git" ] release_version, next_version = get_release_versions(version_file) session.install("build", "twine", "release-helper") # Sanity Checks session.run("release-helper", "version-check-validity", release_version) session.run("release-helper", "version-check-validity", next_version) session.run("release-helper", "directory-check-empty", "dist") session.run("release-helper", "git-check-branch", "main") session.run("release-helper", "git-check-clean") session.run("release-helper", "git-check-tag", release_version, "--does-not-exist") session.run("release-helper", "git-check-remote", "origin", *allowed_upstreams) # Prepare release commit session.run("release-helper", "version-bump", version_file, release_version) session.run("git", "add", version_file, external=True) session.run( "git", "commit", "-m", f"Prepare release: {release_version}", external=True ) # Build the package if os.path.exists("build"): shutil.rmtree("build") session.run("python", "-m", "build") session.run("twine", "check", *glob.glob("dist/*")) # Tag the commit session.run( # fmt: off "git", "tag", release_version, "-m", f"Release {release_version}", "-s", external=True, # fmt: on ) # Prepare back-to-development commit session.run("release-helper", "version-bump", version_file, next_version) session.run("git", "add", version_file, external=True) session.run("git", "commit", "-m", "Back to development", external=True) # Push the commits and tag. session.run("git", "push", "origin", "main", release_version, external=True) # Upload the distributions. session.run("twine", "upload", *glob.glob("dist/*")) sphinx-theme-builder-0.2.0b2/pyproject.toml000066400000000000000000000033701441026434600207150ustar00rootroot00000000000000[build-system] requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [project] name = "sphinx-theme-builder" dynamic = ["version"] authors = [ {name = "Pradyun Gedam", email = "mail@pradyunsg.me"}, ] description = "A tool for authoring Sphinx themes with a simple (opinionated) workflow." readme = "README.md" license = { file = "LICENSE" } dependencies = [ "pyproject-metadata", "packaging", "rich", # for output styling "nodeenv", # for NodeJS isolation "setuptools", # for nodeenv "tomli ; python_version < '3.11'", # for pyproject.toml parsing "typing_extensions ; python_version <= '3.7'", # for Protocol ] requires-python = ">=3.7" classifiers = [ "Development Status :: 2 - Pre-Alpha", "Framework :: Sphinx", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3", "Topic :: Documentation", "Topic :: Software Development :: Documentation", "Topic :: Software Development :: Libraries :: Python Modules", ] [project.optional-dependencies] cli = [ "build", "click", "sphinx-autobuild", ] [project.urls] Documentation = "https://sphinx-theme-builder.readthedocs.io/en/latest/" Source = "https://github.com/pradyunsg/sphinx-theme-builder" [project.scripts] stb = "sphinx_theme_builder._internal.cli:main" [tool.flit.module] name = "sphinx_theme_builder" [tool.coverage.report] exclude_lines = [ # Use a clearer message for what to skip "coverage: skip", # Don't complain if code that needs to be run as main isn't run "if __name__ == .__main__.:", ] sphinx-theme-builder-0.2.0b2/src/000077500000000000000000000000001441026434600165655ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/000077500000000000000000000000001441026434600227665ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/__init__.py000066400000000000000000000075621441026434600251110ustar00rootroot00000000000000"""A tool for authoring Sphinx themes with a simple (opinionated) workflow. This module also serves as the build-backend API for this project. """ __version__ = "0.2.0b2" import contextlib import sys import tempfile from pathlib import Path from typing import Dict, Iterator, Optional import rich from ._internal.distributions import generate_metadata as _generate_metadata from ._internal.distributions import generate_source_distribution as _generate_sdist from ._internal.distributions import generate_wheel_distribution as _generate_wheel from ._internal.errors import DiagnosticError from ._internal.project import Project @contextlib.contextmanager def _ensure_has_metadata( project: Project, metadata_directory: Optional[str] ) -> Iterator[Path]: """Ensure that the body of a with statement, is executed with metadata generated.""" if metadata_directory is not None: yield Path(metadata_directory) else: # The build frontend didn't generate the metadata. Generate it in a temporary # directory, and provide that. Cleans up when the context is exited. with tempfile.TemporaryDirectory() as dirname: metadata_dirname = Path(dirname) metadata_basename = _generate_metadata( project, destination=Path(dirname), ) yield metadata_dirname / metadata_basename @contextlib.contextmanager def _clean_error_presentation() -> Iterator[None]: try: yield except DiagnosticError as error: rich.get_console().print(error, soft_wrap=True) sys.exit(1) # # Source Distributions # def build_sdist( sdist_directory: str, config_settings: Optional[Dict[str, str]] = None, ) -> str: """Generate a source distribution and place it into `sdist_directory`. https://www.python.org/dev/peps/pep-0517/#build-sdist """ with _clean_error_presentation(): project = Project.from_cwd() return _generate_sdist(project, destination=Path(sdist_directory)) # # Wheel Distributions # def prepare_metadata_for_build_wheel( metadata_directory: str, config_settings: Optional[Dict[str, str]] = None, ) -> str: """Generate the metadata (.dist-info) and place it in `metadata_directory`. https://www.python.org/dev/peps/pep-0517/#prepare-metadata-for-build-wheel """ with _clean_error_presentation(): project = Project.from_cwd() return _generate_metadata(project, destination=Path(metadata_directory)) def build_wheel( wheel_directory: str, config_settings: Optional[Dict[str, str]] = None, metadata_directory: Optional[str] = None, ) -> str: """Generate a wheel distribution and place it into `wheel_directory`. https://www.python.org/dev/peps/pep-0517/#build-wheel """ with _clean_error_presentation(): project = Project.from_cwd() with _ensure_has_metadata(project, metadata_directory) as metadata_path: return _generate_wheel( project, destination=Path(wheel_directory), metadata_directory=metadata_path, editable=False, ) # # Editable installs # prepare_metadata_for_build_editable = prepare_metadata_for_build_wheel def build_editable( wheel_directory: str, config_settings: Optional[Dict[str, str]] = None, metadata_directory: Optional[str] = None, ) -> str: """Generate an editable wheel distribution and place it into `wheel_directory`. https://www.python.org/dev/peps/pep-0660/#build-editable """ with _clean_error_presentation(): project = Project.from_cwd() with _ensure_has_metadata(project, metadata_directory) as metadata_path: return _generate_wheel( project, destination=Path(wheel_directory), metadata_directory=metadata_path, editable=True, ) sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/__main__.py000066400000000000000000000001771441026434600250650ustar00rootroot00000000000000"""For when someone really wants to type a lot.""" if __name__ == "__main__": from ._internal.cli import main main() sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/000077500000000000000000000000001441026434600247415ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/__init__.py000066400000000000000000000003021441026434600270450ustar00rootroot00000000000000"""Actual implementation of this package's contents.""" # Do not import anything from this namespace. This namespace is not intended for public # use and may change without warning in any way. sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/cli/000077500000000000000000000000001441026434600255105ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/cli/__init__.py000066400000000000000000000116361441026434600276300ustar00rootroot00000000000000import inspect import sys from typing import Any, Dict, List, Optional, TextIO, Type if sys.version_info >= (3, 8): from typing import Protocol else: from typing_extensions import Protocol import rich from rich.text import Text from ..errors import DiagnosticError try: import build # noqa import click import sphinx_autobuild # type: ignore # noqa except ImportError as import_error: rich.print( DiagnosticError( reference="missing-command-line-dependencies", message=( "Could not import a package that is required for the `stb` command line." ), context=str(import_error), note_stmt=( "The optional [blue]cli[/] dependencies of this package are missing." ), hint_stmt=( "During installation, make sure to include the `\\[cli]`. For example:\n" 'pip install "sphinx-theme-builder\\[cli]"' ), ), file=sys.stderr, ) sys.exit(1) class Command(Protocol): context_settings: Dict[str, Any] interface: List[click.Parameter] def run(self, **kwargs: Dict[str, Any]) -> int: ... def create_click_command(cls: Type[Command]) -> click.Command: # Use the class docstring as the help string help_string = inspect.cleandoc(cls.__doc__) # Infer the name, from the known context. name = cls.__name__[: -len("Command")].lower() # Double check that things are named correctly. assert name.capitalize() + "Command" == cls.__name__ assert name == cls.__module__.split(".")[-1] context_settings: Optional[Dict[str, Any]] = None if hasattr(cls, "context_settings"): context_settings = cls.context_settings command = click.Command( name=name, context_settings=context_settings, help=help_string, params=cls.interface, callback=lambda **kwargs: cls().run(**kwargs), ) return command def compose_command_line() -> click.Group: from .compile import CompileCommand from .new import NewCommand from .npm import NpmCommand from .package import PackageCommand from .serve import ServeCommand command_classes: List[Type[Command]] = [ CompileCommand, # type: ignore NewCommand, # type: ignore PackageCommand, # type: ignore ServeCommand, # type: ignore NpmCommand, # type: ignore ] # Convert our commands into click objects. click_commands = [create_click_command(cls) for cls in command_classes] # Create the main click interface. cli = click.Group( name="stb", help="sphinx-theme-builder helps make it easier to write sphinx themes.", commands={command.name: command for command in click_commands}, # type: ignore ) return cli def present_click_usage_error(error: click.UsageError, *, stream: TextIO) -> None: assert error.ctx rich.print( Text.from_markup("[red]error:[/] ") + Text(error.format_message()), file=stream, ) # Usage usage_parts = error.ctx.command.collect_usage_pieces(error.ctx) usage = " ".join([error.ctx.command_path] + usage_parts) print(file=stream) print("Usage:", file=stream) print(" ", usage, file=stream) # --help option = "--help" command = error.ctx.command_path print(file=stream) rich.print( f"Try [green]{command} {option}[/] for more information.", file=stream, ) def main(args: Optional[List[str]] = None) -> None: """The entrypoint for command line stuff.""" cli = compose_command_line() try: cli(args=args, standalone_mode=False) except click.Abort: rich.print(r"[cyan]\[stb][/] [red]Aborting![/]", file=sys.stderr) sys.exit(1) except click.UsageError as error: present_click_usage_error(error, stream=sys.stderr) sys.exit(error.exit_code) # uses exit codes 1 and 2 except click.ClickException as error: error.show(sys.stderr) sys.exit(error.exit_code) # uses exit codes 1 and 2 except DiagnosticError as error: rich.print(error, file=sys.stderr) sys.exit(3) except Exception: console = rich.console.Console(stderr=True) console.print_exception( width=console.width, show_locals=True, word_wrap=True, suppress=[click] ) console.print( DiagnosticError( reference="crash", message="A fatal error occurred.", context="See above for a detailed Python traceback.", note_stmt=( "If you file an issue, please include the full traceback above." ), hint_stmt=( "This might be due to an issue in sphinx-theme-builder, one of the " "tools it uses internally, or your code." ), ), ) sys.exit(4) sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/cli/compile.py000066400000000000000000000012101441026434600275040ustar00rootroot00000000000000"""Compilation entrypoint.""" from typing import List import click from ..nodejs import generate_assets from ..project import Project class CompileCommand: """Compile the current project's assets.""" interface: List[click.Parameter] = [ click.Option( ["--production"], is_flag=True, default=False, help="Runs the build with `NODE_ENV=production` (`development` by default).", ), ] def run(self, production: bool) -> int: """Make it happen.""" project = Project.from_cwd() generate_assets(project, production=production) return 0 sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/cli/new.py000066400000000000000000000046071441026434600266620ustar00rootroot00000000000000"""Used to initialise the project, with the correct scaffolding.""" import os import subprocess import sys from pathlib import Path from typing import List import click from ..errors import DiagnosticError from ..ui import log _TEMPLATE_URL = "https://github.com/pradyunsg/sphinx-theme-template/archive/refs/heads/sphinx-theme-template-stable.zip" class NewCommand: """Create a new project.""" interface: List[click.Parameter] = [ click.Argument( ["source_directory"], required=True, type=click.Path( exists=False, file_okay=False, dir_okay=True, writable=True, path_type=Path, ), ), ] def run(self, source_directory: Path) -> int: log(f"[yellow]#[/] Looking at [magenta]{source_directory}[/]") pyproject_toml = source_directory / "pyproject.toml" setup_py = source_directory / "setup.py" if pyproject_toml.exists() or setup_py.exists(): raise DiagnosticError( reference="can-not-overwrite-existing-python-project", message="Refusing to generate a new project in provided directory.", context=( "This directory contains a Python project, which will not be " "overwritten." ), hint_stmt=( "This command should be used on empty/non-existing directories." ), ) command = [ sys.executable, "-m", "cookiecutter", "-o", os.fsdecode(source_directory), _TEMPLATE_URL, ] log(f"[magenta]$[/] python {' '.join(command[1:])}") try: subprocess.run(command, check=True) except subprocess.CalledProcessError as error: raise DiagnosticError( reference="cookiecutter-failed", message="Cookiecutter failed to generate the project.", context=f"Exit code: {error.returncode}", note_stmt="cookiecutter's failure output is available above.", hint_stmt=( "[b]@pradyunsg[/] might still need to write/update the supporting " "cookiecutter template. :sweat_smile:" ), ) from error return 0 sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/cli/npm.py000066400000000000000000000023431441026434600266560ustar00rootroot00000000000000"""Used to initialise the project, with the correct scaffolding.""" import subprocess from pathlib import Path from typing import List, Tuple import click from ..errors import DiagnosticError from ..nodejs import _NODEENV_DIR as NODEENV_DIRNAME from ..nodejs import run_in class NpmCommand: """Interact with the npm, available in the environment. If you want to run `npm --help`, use `--` to separate the options for npm: stb npm -- --help """ context_settings = dict( ignore_unknown_options=True, ) interface: List[click.Parameter] = [ click.Argument(["arguments"], nargs=-1, type=click.UNPROCESSED), ] def run(self, arguments: Tuple[str]) -> int: nodeenv = Path.cwd() / NODEENV_DIRNAME if not nodeenv.exists(): raise DiagnosticError( message="Could not find the `.nodeenv` directory to use.", context=str(nodeenv), hint_stmt="Did you run `stb compile` yet?", reference="no-nodeenv", ) try: run_in(nodeenv, ["npm"] + list(arguments)) except subprocess.CalledProcessError as error: return error.returncode else: return 0 sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/cli/package.py000066400000000000000000000011121441026434600274500ustar00rootroot00000000000000"""Compilation entrypoint.""" import subprocess import sys from typing import List import click from ..ui import log class PackageCommand: """Generate Python distribution files. (sdist and wheel)""" interface: List[click.Parameter] = [] def run(self) -> int: """Make it happen.""" log("[magenta]$[/] [blue]python -m build[/]") try: subprocess.run([sys.executable, "-m", "build"], check=True) except subprocess.CalledProcessError as error: return error.returncode log("[green]Done![/]") return 0 sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/cli/serve.py000066400000000000000000000076731441026434600272230ustar00rootroot00000000000000import os import re import subprocess import sys import tempfile from pathlib import Path import click from ..errors import DiagnosticError from ..project import Project from ..ui import log class ServeCommand: """Serve the provided documentation, with livereload on changes.""" interface = [ click.Option( ["--builder"], type=click.Choice(["html", "dirhtml"]), default="html", show_default=True, show_choices=True, help="The Sphinx builder to build the documentation with", ), click.Option( ["--host"], default=None, show_default=True, show_choices=True, help="hostname to serve documentation on (default: 127.0.0.1)", ), click.Option( ["--port"], type=click.INT, default=0, show_default=True, show_choices=True, help="The port to start the server on. Uses a random free port by default.", ), click.Option( ["--open-browser / --no-open-browser"], is_flag=True, default=True, show_default=True, help="Open the browser after starting live-reload server.", ), click.Option( ["--pdb"], is_flag=True, help="Run pdb if the Sphinx build fails with an exception.", ), click.Option( ["--override-theme / --no-override-theme"], is_flag=True, default=False, help="Override the `html_theme` value set in `conf.py`.", ), click.Argument( ["source_directory"], required=True, type=click.Path( exists=True, file_okay=False, dir_okay=True, allow_dash=True, resolve_path=True, path_type=Path, ), ), ] def run( self, *, host: str, port: int, source_directory: Path, builder: str, open_browser: bool, pdb: bool, override_theme: bool, ) -> int: project = Project.from_cwd() with tempfile.TemporaryDirectory() as build_directory: command = [ sys.executable, "-m", "sphinx_autobuild", # sphinx-autobuild flags f"--watch={os.fsdecode(project.source_path)}", f"--re-ignore=({'|'.join(map(re.escape, project.compiled_assets))})", f"--port={port}", # use a random free port f"--pre-build='{sys.executable}' -m sphinx_theme_builder compile", # Sphinx flags "-T", "-a", # full rebuild to ensure static assets are copied on each change f"-b={builder}", os.fsdecode(source_directory), os.fsdecode(build_directory), ] if host: command.append(f"--host={host}") if pdb: command.append("-P") if open_browser: # open the browser for the user command.append("--open-browser") if override_theme: # override the theme, set in conf.py command.extend(["-D", f"html_theme={project.kebab_name}"]) log("Invoking [bold]sphinx-autobuild[/] to run.") try: subprocess.run(command, check=True) except subprocess.CalledProcessError as error: raise DiagnosticError( reference="autobuild-failed", message=f"[b]sphinx-autobuild[/] exited with a non-zero exit code {error.returncode}.", context="See above for failure output from the underlying tooling.", hint_stmt=None, ) from error return 0 sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/distributions.py000066400000000000000000000130131441026434600302130ustar00rootroot00000000000000"""Logic for generating distributions from a :ref:`Project`.""" import io import os import posixpath import subprocess import tarfile import textwrap from pathlib import Path from typing import Callable, Optional, Tuple from .errors import DiagnosticError from .nodejs import generate_assets from .project import Project from .wheelfile import WheelFile, include_parent_paths def _get_vcs_tracked_names(path: Path) -> Optional[Tuple[str, ...]]: if not (path / ".git").is_dir(): return None outb = subprocess.check_output( ["git", "ls-files", "--recurse-submodules", "-z"], cwd=path, ) tracked_files = [ os.fsdecode(location) for location in outb.strip(b"\0").split(b"\0") if location ] return include_parent_paths(tracked_files) def _sdist_filter( project: Project, base: str ) -> Callable[[tarfile.TarInfo], Optional[tarfile.TarInfo]]: """Create a filter to pass to tarfile.add, for this project.""" compiled_assets = project.compiled_assets tracked_names = _get_vcs_tracked_names(project.location) def _filter(tarinfo: tarfile.TarInfo) -> Optional[tarfile.TarInfo]: # Include the entry for the root. if tarinfo.name == base: return tarinfo name = tarinfo.name[len(base) + 1 :] # Exclude hidden files if name.startswith("."): return None # Exclude compiled pyc files. if posixpath.basename(name) == "__pycache__": return None # Exclude compiled assets. if name in compiled_assets: return None # Exclude things that are excluded from version control. if tracked_names is not None and name not in tracked_names: return None return tarinfo # NOTE: The CLI for build needs to check that the compiled assets we have here, are # not tracked under version control. return _filter # # External API # def generate_source_distribution( project: Project, *, destination: Path, ) -> str: """Generate a source distribution for project, and place it inside destination. :return: Name of the generated source tarball. """ os.makedirs(destination, exist_ok=True) dashed_pair = f"{project.snake_name}-{project.metadata.version}" sdist_tarball = destination / f"{dashed_pair}.tar.gz" # NOTE: Post Python 3.7 -- can drop the format kwarg, since it'll be the default. with tarfile.open( name=sdist_tarball, mode="w:gz", format=tarfile.PAX_FORMAT ) as tarball: # Recursively add the files to this tarball, with an exclusion filter. tarball.add( project.location, arcname=dashed_pair, recursive=True, filter=_sdist_filter(project, dashed_pair), ) # Write the metadata file. metadata_content = project.get_metadata_file_contents().encode() tarinfo = tarfile.TarInfo(posixpath.join(dashed_pair, "PKG-INFO")) tarinfo.size = len(metadata_content) tarball.addfile(tarinfo, io.BytesIO(metadata_content)) return sdist_tarball.name def generate_metadata( project: Project, *, destination: Path, ) -> str: """Generate the metadata (.dist-info) and place it in `metadata_directory`. :return: name of the dist-info directory generated. """ dist_info = ( destination / f"{project.snake_name}-{project.metadata.version}.dist-info" ) try: os.makedirs(dist_info) except OSError as error: raise DiagnosticError( message="Metadata directory already exists", context=None, hint_stmt=None, ) from error # Delegated generation. (dist_info / "entry_points.txt").write_text(project.get_entry_points_contents()) (dist_info / "LICENSE").write_text(project.get_license_contents()) (dist_info / "METADATA").write_text(project.get_metadata_file_contents()) # Templated generation. (dist_info / "WHEEL").write_text( textwrap.dedent( """\ Wheel-Version: 1.0 Generator: sphinx-theme-builder {version} Root-Is-Purelib: true Tag: py3-none-any """ ) ) return dist_info.name def generate_wheel_distribution( project: Project, *, destination: Path, metadata_directory: Path, editable: bool, ) -> str: # Generate the JS / CSS assets generate_assets(project, production=not editable) wheel_path = ( destination / f"{project.snake_name}-{project.metadata.version}-py3-none-any.whl" ) tracked_names = _get_vcs_tracked_names(project.location) with WheelFile( path=wheel_path, compiled_assets=project.compiled_assets, tracked_names=tracked_names, ) as wheel: if editable: # Generate a .pth file, for editable installs. There's an enforced src/ # directory, so this is fairly safe to do. wheel.add_string( os.fsdecode(project.source_path.resolve()), dest=project.snake_name + ".pth", ) else: # Add files from the project's src/ directory. wheel.add_directory(project.source_path, dest="", base=project.location) # Put the metadata at the end. # https://www.python.org/dev/peps/pep-0427/#recommended-archiver-features wheel.add_directory(metadata_directory, dest=metadata_directory.name, base=None) wheel.write_record(dest=metadata_directory.name + "/RECORD") return wheel.name sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/errors.py000066400000000000000000000100511441026434600266240ustar00rootroot00000000000000"""Exceptions raised from within this package.""" import re from typing import TYPE_CHECKING, Optional, Union from rich.console import Console, ConsoleOptions, RenderResult from rich.text import Text if TYPE_CHECKING: from typing import Literal _DOCS_URL = "https://sphinx-theme-builder.rtfd.io/errors/#{}" def _is_kebab_case(s: str) -> bool: return re.match(r"^[a-z]+(-[a-z]+)*$", s) is not None def _prefix_with_indent( s: Union[Text, str], console: Console, *, width_offset: int = 0, prefix: str, indent: str, ) -> Text: if isinstance(s, Text): text = s else: text = console.render_str(s) lines = text.wrap(console, console.width - width_offset) return console.render_str(prefix) + console.render_str(f"\n{indent}").join(lines) class DiagnosticError(Exception): reference: str def __init__( self, *, kind: 'Literal["error", "warning"]' = "error", reference: Optional[str] = None, message: Union[str, Text], context: Optional[Union[str, Text]], hint_stmt: Optional[Union[str, Text]], note_stmt: Optional[Union[str, Text]] = None, ) -> None: # Ensure a proper reference is provided. if reference is None: assert hasattr(self, "reference"), "error reference not provided!" reference = self.reference assert _is_kebab_case(reference), "error reference must be kebab-case!" self.kind = kind self.reference = reference self.message = message self.context = context self.note_stmt = note_stmt self.hint_stmt = hint_stmt super().__init__( f"<{self.__class__.__name__}: {_DOCS_URL.format(self.reference)}>" ) def __repr__(self) -> str: return ( f"<{self.__class__.__name__}(" f"reference={self.reference!r}, " f"message={self.message!r}, " f"context={self.context!r}, " f"note_stmt={self.note_stmt!r}, " f"hint_stmt={self.hint_stmt!r}" ")>" ) def __rich_console__( self, console: Console, options: ConsoleOptions, ) -> RenderResult: colour = "red" if self.kind == "error" else "yellow" yield f"[{colour} bold]{self.kind}[/]: [bold]{self.reference}[/]" # Present the main message, with relevant context indented. if not options.ascii_only: yield "" if self.context is not None: yield _prefix_with_indent( self.message, console, width_offset=2, prefix=f"[{colour}]×[/] ", indent=f"[{colour}]│[/] ", ) yield _prefix_with_indent( self.context, console, width_offset=4, prefix=f"[{colour}]╰─>[/] ", indent=f"[{colour}] [/] ", ) else: yield _prefix_with_indent( self.message, console, width_offset=4, prefix="[red]×[/] ", indent=" ", ) else: # coverage: skip yield console.render_str(f"[{colour}]x[/] ") + self.message if self.context is not None: yield "" yield self.context yield "" if self.note_stmt is not None: yield _prefix_with_indent( self.note_stmt, console, width_offset=6, prefix="[magenta bold]note[/]: ", indent=" ", ) if self.hint_stmt is not None: yield _prefix_with_indent( self.hint_stmt, console, width_offset=6, prefix="[cyan bold]hint[/]: ", indent=" ", ) yield f"[bold]link[/]: {_DOCS_URL.format(self.reference)}" sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/nodejs.py000066400000000000000000000267361441026434600266130ustar00rootroot00000000000000"""NodeJS tooling orchestration. Broadly, it has a `.nodeenv` created using the nodeenv package; and ensures that the `node_modules` directory is kept in sync with the `package.json` file of the project. """ import os import shlex import shutil import subprocess import sys import textwrap from pathlib import Path from typing import Any, List, Optional from unittest.mock import patch from rich.markup import escape from .errors import DiagnosticError from .passthrough import passthrough_run from .project import Project from .ui import log _NODEENV_DIR = ".nodeenv" _BIN_DIR = "Scripts" if os.name == "nt" else "bin" def _get_bool_env_var(name: str, *, default: bool) -> bool: value = os.environ.get(name) if value is None: return default if value.lower() in {"false", "0"}: return False if value.lower() in {"true", "1"}: return True raise DiagnosticError( reference="non-boolean-env-variable-value", message=f"The provided value for `{name}` is invalid.", context=f"{name}={escape(value)}", hint_stmt=f"Provide a boolean value for `{name}` (true, false, 1, 0).", ) def run_in( nodeenv: Path, args: List[str], *, production: bool = False, **kwargs: Any ) -> "Optional[subprocess.CompletedProcess[bytes]]": """Run a command, using a binary from `nodeenv`.""" assert nodeenv.name == _NODEENV_DIR log(f"[magenta](nodeenv) $[/] [blue]{' '.join(args)}[/]") env = { "NPM_CONFIG_PREFIX": os.fsdecode(nodeenv), "npm_config_prefix": os.fsdecode(nodeenv), "NODE_PATH": os.fsdecode(nodeenv / "lib" / "node_modules"), "PATH": os.pathsep.join([os.fsdecode(nodeenv / _BIN_DIR), os.environ["PATH"]]), "NODE_ENV": "production" if production else "development", } # Fully qualify the first argument. resolved_name = shutil.which(args[0], path=env["PATH"]) if not resolved_name: raise FileNotFoundError(resolved_name) args[0] = resolved_name with patch.dict("os.environ", env): if not kwargs: returncode = passthrough_run(args) if returncode: cmd = " ".join(shlex.quote(arg) for arg in args) raise subprocess.CalledProcessError(returncode=returncode, cmd=cmd) return None else: return subprocess.run(args, check=True, **kwargs) def _run_python_nodeenv(*args: str) -> None: presentation = ["python", "-m", "nodeenv", *args] log(f"[magenta]$[/] [blue]{' '.join(presentation)}[/]") command = [ sys.executable, "-c", textwrap.dedent( """ import runpy import rich import rich.traceback import urllib.request rich.traceback.install( width=rich.get_console().width, show_locals=True, suppress=[runpy, urllib.request], ) runpy.run_module("nodeenv", run_name="__main__", alter_sys=True) """ ), "-v", *args, ] try: subprocess.run(command, check=True) except subprocess.CalledProcessError as error: raise DiagnosticError( reference="nodeenv-creation-failed", message="Failed to create a `nodeenv`", context="See above for failure output from the underlying tooling.", hint_stmt=( "A `urllib.error.HTTPError` indicates that the issue is " "related to the network or the availability of NodeJS release files. " "It may mean the node version that this tool is trying to fetch is no " "longer available, for example if there is no compatible NodeJS binary " "for the operating system." ), ) from error def _should_use_system_node(node_version: str) -> bool: if not _get_bool_env_var("STB_USE_SYSTEM_NODE", default=False): return False if sys.platform == "win32": raise DiagnosticError( reference="can-not-use-system-node-on-windows", message="sphinx-theme-builder can not use the system node on Windows.", context="The underlying tooling (nodeenv) does not yet support this.", hint_stmt="Unset `STB_USE_SYSTEM_NODE`, which is currently set to 'true'", ) return True def _relaxed_version_check(expected: str, got: str) -> Optional[str]: """Perform a relaxed check of the failure mode. Returns None, if the versions match the relaxed check criterion. Returns a string, with the reason for the the version check failure. """ assert expected.startswith("v"), expected assert got.startswith("v"), got expected_parts = expected[1:].split(".")[:2] got_parts = got[1:].split(".")[:2] assert len(expected_parts) == len(got_parts) == 2, f"{expected};{got}" if expected_parts[0] != got_parts[0]: return "Minor version does not match the value declared in the theme." if int(expected_parts[1]) < int(got_parts[1]): return "Minor version is lower than the value declared in the theme." return None def ensure_version_matches(expected: str, got: str) -> None: """Ensure that the version of NodeJS matches the expected value. STB_RELAX_NODE_VERSION_CHECK can be used to relax the check, to only check the major and minor version. """ if got == expected: return if not _get_bool_env_var("STB_RELAX_NODE_VERSION_CHECK", default=False): raise DiagnosticError( reference="nodeenv-version-mismatch", message="The `nodeenv` for this project is unhealthy.", context=( "There is a mismatch between what is present in the environment " f"({got}) and the expected version of NodeJS ({expected})." ), hint_stmt=( f"Deleting the {_NODEENV_DIR} directory and trying again may work." ), ) log("[yellow]#[/] The node version is not the expected one.") log("[yellow]#[/] The generated assets may be broken even if the build succeeds.") log("[yellow]#[/] Continuing anyway - `STB_RELAX_NODE_VERSION_CHECK` is truthy.") rejection_reason = _relaxed_version_check(expected, got) if rejection_reason is None: return raise DiagnosticError( reference="node-version-mismatch", message="The node version is not compatible with the expected version.", context=f"{rejection_reason}\nSee above for the expected and actual version.", hint_stmt=( "You need to use a compatible version of NodeJS to build the theme. " ), ) def create_nodeenv(nodeenv: Path, node_version: str) -> None: """Create a `nodeenv` for the theme.""" log( "[yellow]#[/] [cyan]Generating new [magenta]nodeenv[/] with " f"NodeJS [green]{node_version}[/]!" ) if _should_use_system_node(node_version=node_version): log("[yellow]#[/] Will use system nodeJS.") node_version = "system" envdir = os.fsdecode(nodeenv) # This next bit is borrowed from # https://github.com/pre-commit/pre-commit/blob/v2.16.0/pre_commit/languages/node.py if sys.platform == "win32": # pragma: no cover envdir = "\\\\?\\" + os.path.normpath(envdir) _run_python_nodeenv( f"--node={node_version}", "--prebuilt", "--clean-src", envdir, ) def run_npm_build(nodeenv: Path, *, production: bool) -> None: try: run_in(nodeenv, ["npm", "run-script", "build"], production=production) except subprocess.CalledProcessError as error: raise DiagnosticError( reference="js-build-failed", message="The Javascript-based build pipeline failed.", context="See above for failure output from the underlying tooling.", hint_stmt=None, ) from error def populate_npm_packages(nodeenv: Path, node_modules: Path) -> None: try: run_in(nodeenv, ["npm", "install", "--include=dev"]) except FileNotFoundError as error: raise DiagnosticError( reference="nodeenv-unhealthy-npm-not-found", message="The `nodeenv` for this project is unhealthy.", context=str(error), hint_stmt=( f"Deleting the {_NODEENV_DIR} directory and trying again may work." ), ) from error except subprocess.CalledProcessError as error: raise DiagnosticError( reference="js-install-failed", message="Javascript dependency installation failed.", context="See above for failure output from the underlying tooling.", hint_stmt=None, ) from error if node_modules.is_dir(): node_modules.touch() def generate_assets(project: Project, *, production: bool) -> None: package_json = project.location / "package.json" nodeenv = project.location / _NODEENV_DIR node_modules = project.location / "node_modules" assert package_json.exists() created_new_nodeenv = False if not nodeenv.exists(): log("[yellow]#[/] [magenta]nodeenv[cyan] does not exist.[/]") create_nodeenv(nodeenv, project.node_version) created_new_nodeenv = True # Checking the node version is a sanity check, and ensures that the environment is # "healthy". try: process = run_in(nodeenv, ["node", "--version"], stdout=subprocess.PIPE) except FileNotFoundError as error: raise DiagnosticError( reference="nodeenv-unhealthy-file-not-found", message="The `nodeenv` for this project is unhealthy.", context=str(error), hint_stmt=( f"Deleting the {_NODEENV_DIR} directory and trying again may work." ), ) from error except subprocess.CalledProcessError as error: raise DiagnosticError( reference="nodeenv-unhealthy-subprocess-failure", message="The `nodeenv` for this project is unhealthy.", context="See above for failure output from the underlying tooling.", hint_stmt=( f"Deleting the {_NODEENV_DIR} directory and trying again may work." ), ) from error # Present the `node --version` output to the user. assert process got = process.stdout.decode().strip() print(got) # Sanity-check the node version. expected = f"v{project.node_version}" ensure_version_matches(expected, got) need_to_populate = False if created_new_nodeenv: need_to_populate = True elif not node_modules.exists(): need_to_populate = True elif node_modules.stat().st_mtime < package_json.stat().st_mtime: log("[yellow]#[/] [cyan]Detected changes in [magenta]package.json[cyan].[/]") need_to_populate = True if need_to_populate: if node_modules.exists(): log("[yellow]#[/] [cyan]Cleaning up [magenta]node_modules[cyan].[/]") try: shutil.rmtree(node_modules) except OSError as error: raise DiagnosticError( reference="unable-to-cleanup-node-modules", message="Could not remove node_modules directory.", context=str(error), hint_stmt=f"Deleting {node_modules} and trying again may work.", ) from error log("[yellow]#[/] [cyan]Installing NodeJS packages.[/]") populate_npm_packages(nodeenv, node_modules) run_npm_build(nodeenv=nodeenv, production=production) log("[green]Done![/]") sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/passthrough.py000066400000000000000000000076371441026434600276770ustar00rootroot00000000000000"""CLI passthrough, on pty-supporting systems. Based on code licensed as: Copyright (c) 2015-2017, Terminal Labs All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Terminal Labs nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL TERMINAL LABS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Largely adapted from https://github.com/terminal-labs/cli-passthrough at commit 9337fbbaa59d12a1daaaba31a6dec9ce3107f8c7 (filename: cli_passthrough/_passthrough.py), which in turn was adapted from StackOverflow. """ import sys from typing import List if sys.platform != "win32": import errno import fcntl import os import pty import shutil import struct import termios from itertools import chain from select import select from subprocess import Popen (_COLUMNS, _ROWS) = shutil.get_terminal_size(fallback=(80, 20)) def _set_size(fd: int) -> None: """Found at: https://stackoverflow.com/a/6420070""" size = struct.pack("HHHH", _ROWS, _COLUMNS, 0, 0) fcntl.ioctl(fd, termios.TIOCSWINSZ, size) def passthrough_run(args: List[str]) -> int: """Largely found in https://stackoverflow.com/a/31953436""" masters, slaves = zip(pty.openpty(), pty.openpty()) for fd in chain(masters, slaves): _set_size(fd) with Popen(args, stdin=sys.stdin, stdout=slaves[0], stderr=slaves[1]) as p: for fd in slaves: os.close(fd) # no input readable = { masters[0]: sys.stdout.buffer, # store buffers separately masters[1]: sys.stderr.buffer, } while readable: for fd in select(readable, [], [])[0]: try: data = os.read(fd, 1024) # read available except OSError as e: if e.errno != errno.EIO: # time to clean up! p.kill() for fd in masters: os.close(fd) raise del readable[fd] # EIO means EOF on some systems else: if not data: # EOF del readable[fd] else: readable[fd].write(data) readable[fd].flush() for fd in masters: os.close(fd) return p.returncode else: import subprocess def passthrough_run(args: List[str]) -> int: p = subprocess.run(args) return p.returncode sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/project.py000066400000000000000000000426541441026434600267740ustar00rootroot00000000000000"""Metadata and file system logic goes here.""" import ast import configparser from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple import pyproject_metadata from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version from rich.text import Text try: import tomllib except ImportError: import tomli as tomllib from .errors import DiagnosticError if TYPE_CHECKING: from typing import Literal def read_toml_file(path: Path) -> Dict[str, Any]: with path.open("rb") as stream: return tomllib.load(stream) def get_version_using_ast(contents: bytes) -> Optional[str]: """Extract the version from the given file, using the Python AST.""" tree = ast.parse(contents) # Only need to check the top-level nodes, and not recurse deeper. version: Optional[str] = None for child in tree.body: # Look for a simple string assignment to __version__ if ( isinstance(child, ast.Assign) and len(child.targets) == 1 and isinstance(child.targets[0], ast.Name) and child.targets[0].id == "__version__" and isinstance(child.value, ast.Str) ): version = child.value.s break return version class ImproperProjectMetadata(DiagnosticError): """For issues with pyproject.toml contents or other metadata.""" class InvalidProjectStructure(DiagnosticError): """For issues with the project structure.""" def _load_pyproject(pyproject: Path) -> Tuple[str, Dict[str, Any]]: """Load from the pyproject.toml file, doing the minimal sanity checks.""" try: pyproject_data = read_toml_file(pyproject) except FileNotFoundError as error: raise InvalidProjectStructure( message="Could not find a `pyproject.toml`.", context=f"Looked at {pyproject}", hint_stmt="Is this a valid Python package?", reference="pyproject-missing", ) from error except tomllib.TOMLDecodeError as error: raise ImproperProjectMetadata( message="Could not parse `pyproject.toml`.", context=f"{error}\npath: {pyproject}", hint_stmt=None, reference="pyproject-could-not-parse", ) from error project = pyproject_data.get("project", None) if project is None: raise ImproperProjectMetadata( message=Text("Could not find [project] table."), context=f"in file {pyproject}", hint_stmt=None, reference="pyproject-no-project-table", ) kebab_name = project.get("name", None) if kebab_name is None: raise ImproperProjectMetadata( message=Text("Could not find `name` in [project] table."), context=f"in file {pyproject}", hint_stmt=None, reference="pyproject-no-name-in-project-table", ) canonical_name = canonicalize_name(kebab_name) if kebab_name != canonical_name: raise ImproperProjectMetadata( message=Text("Found non-canonical `name` declared in the [project] table."), context=( f"Got '{kebab_name}', expected '{canonical_name}'\n" f"in file {pyproject}" ), hint_stmt=None, reference="pyproject-non-canonical-name", ) return kebab_name, pyproject_data def _determine_version( package_path: Path, kebab_name: str, pyproject_data: Dict[str, Any] ) -> 'Tuple[str, Literal["pyproject.toml", "__init__.py"]]': # Let's look for the version now! declared_in_python = None # type: Optional[str] declared_in_pyproject = None # type: Optional[str] metadata = pyproject_data["project"] # Load the version from pyproject.toml file, if provided. if "version" in metadata: declared_in_pyproject = metadata["version"] if not isinstance(declared_in_pyproject, str): raise ImproperProjectMetadata( message=Text( "Expected 'version' in the [project] table to be a string." ), context=( f"Got {declared_in_pyproject} which is {type(declared_in_pyproject)} instead." ), hint_stmt=None, reference="pyproject-invalid-version", ) # Load the version from __init__ file, if provided. package_init_file = package_path / "__init__.py" if not package_init_file.exists(): raise InvalidProjectStructure( message=f"{package_init_file} is missing.", context=( "It is required for using sphinx-theme-builder.\n" f"The declared project name is {kebab_name}." ), hint_stmt=None, reference="project-init-missing", ) try: declared_in_python = get_version_using_ast(package_init_file.read_bytes()) except SyntaxError as error: raise InvalidProjectStructure( message="Could not parse `__init__.py` file", context=f"In file {package_init_file}\n{error!r}", hint_stmt=None, reference="project-init-invalid-syntax", ) from error if declared_in_pyproject and declared_in_python: raise InvalidProjectStructure( message="Found version declaration in both `pyproject.toml` and `__init__.py`.", context=( "The package version MUST only be provided information in one location" f"\nFrom `pyproject.toml`, got {declared_in_pyproject}" f"\nFrom `__init__.py`, got {declared_in_python}" ), hint_stmt=Text( "It is a good idea to declare the version in Python alone.\n" "You can do this by removing `project.version` from `pyproject.toml` " 'and including "version" in the `project.dynamic` list. Like so:\n\n' "[project]\n" 'dynamic = ["version"]' ), reference="project-double-version-declaration", ) elif declared_in_python: if "version" not in metadata.get("dynamic", []): raise ImproperProjectMetadata( message=( "Found version in `__init__.py` but pyproject.toml does not " 'include "version" in `project.dynamic` list.' ), context=f"From `__init__.py`, got version {declared_in_python}", hint_stmt=Text( 'You solve this by including "version" in the `project.dynamic` ' "list. Like so:\n\n" "[project]\n" 'dynamic = ["version"]' ), reference="project-missing-dynamic-version", ) return (declared_in_python, "__init__.py") elif declared_in_pyproject: if "version" in metadata.get("dynamic", []): raise ImproperProjectMetadata( message=( 'Declared "version" as `dynamic`, while also using `version` key ' "in `pyproject.toml`." ), context=f"From `pyproject.toml`, got version {declared_in_pyproject}.", hint_stmt=( "You can solve this by removing `version` from `project.dynamic`." ), reference="project-improper-dynamic-version", ) return (declared_in_pyproject, "pyproject.toml") raise InvalidProjectStructure( message="No version declaration found for project.", context=f"Looked at {package_init_file} and corresponding `pyproject.toml`.", hint_stmt=( "Did you forget to declare the version? " "It's the 'project.version' key in `pyproject.toml` " "or the `__version__` attribute in `__init__.py`." ), reference="project-no-version-declaration", ) @dataclass(frozen=True) class Project: """Represents a project to be built.""" kebab_name: str location: Path metadata: pyproject_metadata.StandardMetadata theme_name: str node_version: str additional_compiled_static_assets: List[str] @classmethod def from_cwd(cls) -> "Project": retval = cls.from_path(Path.cwd()) retval.validate_file_structure_and_contents() return retval @classmethod def from_path(cls, path: Path) -> "Project": """Load a project from given Path.""" pyproject = path / "pyproject.toml" kebab_name, pyproject_data = _load_pyproject(pyproject) # IMPORTANT: Keep in sync with `python_package_path` below. package_path = path / "src" / kebab_name.replace("-", "_") # Get the version, and validate it. version_s, version_comes_from = _determine_version( package_path, kebab_name, pyproject_data ) try: version = Version(version_s) except InvalidVersion as error: raise ImproperProjectMetadata( message=f"Got an invalid version: {version_s}", context=f"This came from `{version_comes_from}`", hint_stmt=None, reference="project-invalid-version", ) from error # Get the metadata, and validate it. try: metadata = pyproject_metadata.StandardMetadata.from_pyproject( pyproject_data, path ) except pyproject_metadata.ConfigurationError as error: raise InvalidProjectStructure( message="Provided project metadata is not valid.", context=str(error), hint_stmt="This is related to the contents of the pyproject.toml file.", reference="invalid-pyproject-toml", ) if metadata.license is None: raise ImproperProjectMetadata( message="No license is declared in `pyproject.toml`.", context=( "It is required for this to be packaged using sphinx-theme-builder." ), hint_stmt="This is related to the contents of the pyproject.toml file.", reference="no-license-declared", ) # Ensure that nothing other than the version is dynamic. metadata.version = version try: metadata.dynamic.remove("version") except ValueError: pass # it doesn't exist, that's fine. if metadata.dynamic: raise ImproperProjectMetadata( message="Got unsupported keys for dynamic metadata.", context=str(metadata.dynamic), hint_stmt="This is related to the contents of the pyproject.toml file.", reference="unsupported-dynamic-keys", ) # TODO: Factor this out, and add proper error messages. tool_config = pyproject_data.get("tool", {}).get("sphinx-theme-builder", {}) # Get the node-version. This gets validated when executing a compilation. try: node_version = tool_config["node-version"] except KeyError: raise ImproperProjectMetadata( message="Did not get any node-version, from pyproject.toml file.", context=( "It is required for this to be packaged using sphinx-theme-builder." ), hint_stmt=Text( "Did you set node-version in [tool.sphinx-theme-builder]?" ), reference="no-node-version", ) try: theme_name = tool_config["theme-name"] except KeyError: theme_name = kebab_name try: additional_compiled_static_assets = tool_config[ "additional-compiled-static-assets" ] except KeyError: additional_compiled_static_assets = [] return Project( kebab_name=kebab_name, metadata=metadata, location=path, theme_name=theme_name, node_version=node_version, additional_compiled_static_assets=additional_compiled_static_assets, ) @property def snake_name(self) -> str: return self.kebab_name.replace("-", "_") @property def source_path(self) -> Path: return self.location / "src" @property def python_package_path(self) -> Path: return self.source_path / self.snake_name @property def theme_path(self) -> Path: return self.python_package_path / "theme" / self.theme_name @property def theme_static_path(self) -> Path: return self.theme_path / "static" @property def assets_path(self) -> Path: return self.python_package_path / "assets" @property def theme_conf_path(self) -> Path: return self.theme_path / "theme.conf" @property def input_scripts_path(self) -> Path: return self.assets_path / "scripts" @property def output_script_path(self) -> Path: return self.theme_static_path / "scripts" / (self.kebab_name + ".js") @property def input_stylesheets_path(self) -> Path: return self.assets_path / "styles" @property def output_stylesheet_path(self) -> Path: return self.theme_static_path / "styles" / (self.kebab_name + ".css") @property def compiled_assets(self) -> Tuple[str, ...]: """A sequence of compiled assets, as relative POSIX paths (to project root).""" compiled_assets = [ self.output_script_path, self.output_stylesheet_path, ] compiled_assets.extend( [ self.theme_static_path / asset_name for asset_name in self.additional_compiled_static_assets ] ) files = tuple( path.relative_to(self.location).as_posix() for path in compiled_assets ) # Tack on the .map files return files + tuple(map((lambda x: x + ".map"), files)) def validate_file_structure_and_contents(self) -> None: """Validate the project's file structure and check that it matches the template. This is an important diagnostic step, to present useful and clear error messages to users who are transitioning from an existing theme to this one. """ # # File presence # package_init_file = self.python_package_path / "__init__.py" assert ( package_init_file.exists() ), "This should've been validated in `Project.from_path`" if not self.theme_conf_path.exists(): raise InvalidProjectStructure( message=f"{self.theme_conf_path} does not exist.", context="It is required for using sphinx-theme-builder.", hint_stmt="Did you set the correct theme-name in pyproject.toml?", reference="missing-theme-conf", ) if not self.input_scripts_path.exists(): raise InvalidProjectStructure( message=f"{self.input_scripts_path} does not exist.", context="It is required for using sphinx-theme-builder.", hint_stmt=None, reference="missing-javascript", ) if not self.input_stylesheets_path.exists(): raise InvalidProjectStructure( message=f"{self.input_stylesheets_path} does not exist.", context="It is required for using sphinx-theme-builder.", hint_stmt=None, reference="missing-stylesheet", ) # # File contents # expected_stylesheet = self.output_stylesheet_path.relative_to( self.theme_static_path ).as_posix() theme_conf_parser = configparser.ConfigParser() try: with self.theme_conf_path.open() as file: theme_conf_parser.read_file(file, source=file.name) specified_stylesheet = theme_conf_parser.get("theme", "stylesheet") except (OSError, configparser.Error) as error: raise InvalidProjectStructure( message=f"Could not open/parse {self.theme_conf_path}.", context=str(error), hint_stmt=None, reference="could-not-read-theme-conf", ) from error if specified_stylesheet != expected_stylesheet: raise InvalidProjectStructure( message=f"Incorrect stylesheet set in {self.theme_conf_path}.", context=f"Expected {expected_stylesheet}, got {specified_stylesheet}.", hint_stmt=None, reference="theme-conf-incorrect-stylesheet", ) def get_metadata_file_contents(self) -> str: """Get contents for the `METADATA` file in a wheel.""" return str(self.metadata.as_rfc822()) def get_license_contents(self) -> str: """Get contents for the `LICENSE` file in a wheel.""" return self.metadata.license.text def get_entry_points_contents(self) -> str: """Get contents for the `entry_points.txt` file in a wheel.""" lines: List[str] = [] for group, mapping in self.metadata.entrypoints.items(): if lines: lines.append("") # blank line, for visual clarity lines.append(f"[{group}]") for name, entrypoint in mapping.items(): lines.append(f"{name} = {entrypoint}") return "\n".join(lines) sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/ui.py000066400000000000000000000002221441026434600257240ustar00rootroot00000000000000"""User Interface helpers.""" from typing import Any import rich def log(*objects: Any) -> None: rich.print(r"[cyan]\[stb][/]", *objects) sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/_internal/wheelfile.py000066400000000000000000000150141441026434600272600ustar00rootroot00000000000000"""A helper class to make it easier to generate a wheel. """ import base64 import hashlib import os import posixpath import zipfile from dataclasses import dataclass from pathlib import Path, PurePosixPath from types import TracebackType from typing import BinaryIO, List, Optional, Set, Tuple, Type _HASH_ALGORITHM = "sha256" # Borrowed from CPython's shutil.py # https://github.com/python/cpython/blob/v3.9.1/Lib/shutil.py#L52 _WINDOWS = os.name == "nt" _COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024 def encode_for_record(hasher: "hashlib._Hash") -> str: """Takes a hasher and returns the encoded hash value for the RECORD.""" return base64.urlsafe_b64encode(hasher.digest()).decode("ascii").rstrip("=") # Adapted from pradyunsg/installer # https://github.com/pradyunsg/installer/blob/0.7.0/src/installer/utils.py#L95 def copyfileobj_with_hashing( source: BinaryIO, dest: BinaryIO, hash_algorithm: str, ) -> Tuple[str, int]: """Copy a buffer while computing the content's hash and size. Copies the source buffer into the destination buffer while computing the hash of the contents. Adapted from :any:`shutil.copyfileobj`. :param source: buffer holding the source data :param dest: destination buffer :param hash_algorithm: hashing algorithm :return: size, hash digest of the contents """ hasher = hashlib.new(hash_algorithm) size = 0 while True: buf = source.read(_COPY_BUFSIZE) if not buf: break hasher.update(buf) dest.write(buf) size += len(buf) return encode_for_record(hasher), size def include_parent_paths(posix_style_paths: List[str]) -> Tuple[str, ...]: names: Set[str] = set() for path_str in posix_style_paths: path = PurePosixPath(path_str) names.update(parent.as_posix() for parent in path.parents) names.add(path_str) return tuple(names) @dataclass class RecordEntry: """A single entry in a RECORD file.""" path: str hash_value: str hash_algorithm: str size: str def to_line(self) -> str: if self.hash_value: return ",".join( (self.path, f"{self.hash_algorithm}={self.hash_value}", self.size) ) else: assert not self.hash_algorithm return ",".join((self.path, "", self.size)) class WheelFile: """A helper class to generate wheels.""" def __init__( self, *, path: Path, tracked_names: Optional[Tuple[str, ...]], compiled_assets: Tuple[str, ...], ) -> None: self._path = path self._tracked_names = tracked_names self._compiled_assets = compiled_assets self._compiled_assets_and_parents = include_parent_paths(list(compiled_assets)) self._zipfile = zipfile.ZipFile(path, mode="w") self._records: List[RecordEntry] = [] def __enter__(self) -> "WheelFile": return self def __exit__( self, type_: Optional[Type[BaseException]], value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: self._zipfile.close() def _exclude(self, path: Path, *, base: Optional[Path]) -> bool: # Exclude compiled pyc files. if path.name == "__pycache__": return True # Include all not-based-on-source-tree files. if base is None: return False normalised_path = path.relative_to(base).as_posix() # Definitely include compiled assets. if normalised_path in self._compiled_assets_and_parents: return False for asset_path in self._compiled_assets: if normalised_path.startswith(asset_path + "/"): return False # Exclude things that are excluded from version control. if self._tracked_names is not None: if normalised_path not in self._tracked_names: return True # If we're here, we've excluded all the files that needed to be excluded. return False @property def name(self) -> str: """Name of the wheel.""" return self._path.name def write_record(self, *, dest: str) -> None: """Write the record, in the provided destination. :param dest: The exact ``{package}-{version}.dist-info/RECORD`` to write to. """ assert self._zipfile.fp is not None self._records.append(RecordEntry(dest, "", "", "")) lines = [record.to_line() for record in self._records] self._zipfile.writestr(dest, data="\n".join(lines)) def add_string(self, content: str, *, dest: str) -> None: """Add a file at ``dest``, with the given ``content``.""" assert self._zipfile.fp is not None data = content.encode() hasher = hashlib.new(_HASH_ALGORITHM, data=data) self._zipfile.writestr(dest, data=data) self._records.append( RecordEntry( path=dest, hash_algorithm=_HASH_ALGORITHM, hash_value=encode_for_record(hasher), size=str(len(data)), ) ) def add_file(self, file: Path, *, dest: str, base: Optional[Path]) -> None: """Add a file at ``dest``, with the contents of ``file``.""" assert self._zipfile.fp is not None if self._exclude(file, base=base): return # Copy the file object. zipinfo = zipfile.ZipInfo.from_file(file, dest) with file.open("rb") as source_stream: with self._zipfile.open(zipinfo, "w") as dest_stream: hash_value, size = copyfileobj_with_hashing( source_stream, dest_stream, hash_algorithm=_HASH_ALGORITHM ) self._records.append( RecordEntry( path=dest, hash_algorithm=_HASH_ALGORITHM, hash_value=hash_value, size=str(size), ) ) def add_directory( self, directory: Path, *, dest: str, base: Optional[Path] ) -> None: """Add the directory to the archive, recursively.""" assert directory.is_dir() assert self._zipfile.fp is not None if self._exclude(directory, base=base): return for item in sorted(directory.iterdir()): if item.is_dir(): self.add_directory( item, dest=posixpath.join(dest, item.name), base=base ) else: self.add_file(item, dest=posixpath.join(dest, item.name), base=base) sphinx-theme-builder-0.2.0b2/src/sphinx_theme_builder/testing.py000066400000000000000000000000431441026434600250120ustar00rootroot00000000000000"""TODO: Write testing helpers.""" sphinx-theme-builder-0.2.0b2/tests/000077500000000000000000000000001441026434600171405ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/tests/README.md000066400000000000000000000006271441026434600204240ustar00rootroot00000000000000# sphinx-theme-builder's tests We're using tests at 4 levels: - "unit" -- check behaviour of one function/method at a time. - no mocking. - "integration" -- check that units exchange information correctly. - mocking allowed. - "system" -- check that the entire pipeline passes information correctly. - no mocking. - "workflow" -- check that specific end-user behaviours are correct. - no mocking. sphinx-theme-builder-0.2.0b2/tests/__init__.py000066400000000000000000000000001441026434600212370ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/tests/conftest.py000066400000000000000000000033131441026434600213370ustar00rootroot00000000000000import contextlib import errno import os import shutil import stat import tempfile from pathlib import Path from types import TracebackType from typing import Any, Callable, Generator, Tuple, Type import click import pytest from click.testing import CliRunner # -------------------------------------------------------------------------------------- # Fixtures @pytest.fixture def runner() -> CliRunner: return CliRunner(mix_stderr=False) @pytest.fixture def cli() -> click.Group: from sphinx_theme_builder._internal.cli import compose_command_line return compose_command_line() # -------------------------------------------------------------------------------------- # Work around https://github.com/pytest-dev/pytest/issues/7821 @contextlib.contextmanager def tmpdir() -> Generator[Path, None, None]: """Contextmanager to create a temporary directory. It will be cleaned up afterwards. """ tempdir = tempfile.mkdtemp() try: yield Path(tempdir) finally: rmtree(tempdir) def rmtree(path: str) -> None: """On windows, rmtree fails for readonly dirs.""" def handle_remove_readonly( func: Callable[..., Any], path: str, exc: Tuple[Type[OSError], OSError, TracebackType], ) -> None: excvalue = exc[1] if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES: for p in (path, os.path.dirname(path)): os.chmod(p, os.stat(p).st_mode | stat.S_IWUSR) func(path) else: raise # noqa shutil.rmtree(path, ignore_errors=False, onerror=handle_remove_readonly) # -------------------------------------------------------------------------------------- sphinx-theme-builder-0.2.0b2/tests/integration/000077500000000000000000000000001441026434600214635ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/tests/integration/test_cli_compile.py000066400000000000000000000026561441026434600253640ustar00rootroot00000000000000from unittest import mock from click import Group from click.testing import CliRunner class TestCompileCommand: """`stb compile`""" def test_calls_generate_assets(self, runner: CliRunner, cli: Group) -> None: with mock.patch( "sphinx_theme_builder._internal.cli.compile.generate_assets" ) as mocked_generate_assets, mock.patch( "sphinx_theme_builder._internal.cli.compile.Project" ) as mocked_project: with runner.isolated_filesystem(): process = runner.invoke(cli, ["compile"]) assert process.exit_code == 0, process mocked_generate_assets.assert_has_calls( [ mock.call(mocked_project.from_cwd(), production=False), ] ) def test_calls_generate_assets_in_production( self, runner: CliRunner, cli: Group ) -> None: with mock.patch( "sphinx_theme_builder._internal.cli.compile.generate_assets" ) as mocked_generate_assets, mock.patch( "sphinx_theme_builder._internal.cli.compile.Project" ) as mocked_project: with runner.isolated_filesystem(): process = runner.invoke(cli, ["compile", "--production"]) assert process.exit_code == 0, process mocked_generate_assets.assert_has_calls( [ mock.call(mocked_project.from_cwd(), production=True), ] ) sphinx-theme-builder-0.2.0b2/tests/integration/test_cli_new.py000066400000000000000000000045721441026434600245240ustar00rootroot00000000000000import subprocess import sys from pathlib import Path from unittest import mock from click import Group from click.testing import CliRunner from sphinx_theme_builder._internal.cli.new import _TEMPLATE_URL from sphinx_theme_builder._internal.errors import DiagnosticError class TestNewCommand: """`stb new`""" def test_aborts_when_setup_py_exists(self, runner: CliRunner, cli: Group) -> None: with runner.isolated_filesystem() as directory: (Path(directory) / "setup.py").write_text("") with mock.patch("subprocess.run") as mocked_run: process = runner.invoke(cli, ["new", directory]) assert mocked_run.call_count == 0 assert process.exit_code == 1, process def test_aborts_when_pyproject_toml_exists( self, runner: CliRunner, cli: Group ) -> None: with runner.isolated_filesystem() as directory: (Path(directory) / "pyproject.toml").write_text("") with mock.patch("subprocess.run") as mocked_run: process = runner.invoke(cli, ["new", directory]) assert mocked_run.call_count == 0 assert process.exit_code == 1, process def test_calls_cookiecutter(self, runner: CliRunner, cli: Group) -> None: with runner.isolated_filesystem() as directory: with mock.patch("subprocess.run") as mocked_run: process = runner.invoke(cli, ["new", directory]) assert process.exit_code == 0, process assert mocked_run.call_count == 1 assert mocked_run.call_args == mock.call( [sys.executable, "-m", "cookiecutter", "-o", directory, _TEMPLATE_URL], check=True, ) def test_cookiecutter_failure(self, runner: CliRunner, cli: Group) -> None: with runner.isolated_filesystem() as directory: with mock.patch("subprocess.run") as mocked_run: mocked_run.side_effect = subprocess.CalledProcessError( returncode=2, cmd="placeholder" ) process = runner.invoke(cli, ["new", directory]) assert mocked_run.call_count == 1 assert mocked_run.call_args == mock.call( [sys.executable, "-m", "cookiecutter", "-o", directory, _TEMPLATE_URL], check=True, ) assert process.exit_code == 1, process assert isinstance(process.exception, DiagnosticError) sphinx-theme-builder-0.2.0b2/tests/integration/test_cli_package.py000066400000000000000000000006701441026434600253210ustar00rootroot00000000000000import sys from unittest import mock from click import Group from click.testing import CliRunner class TestPackageCommand: """`stb package`""" def test_calls_build(self, runner: CliRunner, cli: Group) -> None: with mock.patch("subprocess.run") as mocked_run: runner.invoke(cli, ["package"]) mocked_run.assert_has_calls( [mock.call([sys.executable, "-m", "build"], check=True)] ) sphinx-theme-builder-0.2.0b2/tests/requirements.txt000066400000000000000000000000561441026434600224250ustar00rootroot00000000000000pytest pytest-clarity pytest-cov pytest-pspec sphinx-theme-builder-0.2.0b2/tests/unit/000077500000000000000000000000001441026434600201175ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/tests/unit/test_errors.py000066400000000000000000000276561441026434600230640ustar00rootroot00000000000000"""Test the presentation style of exceptions.""" import textwrap from io import StringIO from typing import TYPE_CHECKING, Optional import pytest import rich import rich.console import rich.text from sphinx_theme_builder._internal.errors import DiagnosticError if TYPE_CHECKING: from typing import Literal class TestDiagnosticErrorInitialisation: def test_fails_without_reference(self) -> None: class DerivedError(DiagnosticError): pass with pytest.raises(AssertionError) as exc_info: DerivedError(message="", context=None, hint_stmt=None) assert str(exc_info.value) == "error reference not provided!" def test_can_fetch_reference_from_subclass(self) -> None: class DerivedError(DiagnosticError): reference = "subclass-reference" obj = DerivedError(message="", context=None, hint_stmt=None) assert obj.reference == "subclass-reference" def test_can_fetch_reference_from_arguments(self) -> None: class DerivedError(DiagnosticError): pass obj = DerivedError( message="", context=None, hint_stmt=None, reference="subclass-reference" ) assert obj.reference == "subclass-reference" @pytest.mark.parametrize( "name", [ "BADNAME", "BadName", "bad_name", "BAD_NAME", "_bad", "bad-name-", "bad--name", "-bad-name", "bad-name-due-to-1-number", ], ) def test_rejects_non_kebab_case_names(self, name: str) -> None: class DerivedError(DiagnosticError): reference = name with pytest.raises(AssertionError) as exc_info: DerivedError(message="", context=None, hint_stmt=None) assert str(exc_info.value) == "error reference must be kebab-case!" def assert_presentation_matches( error: DiagnosticError, expected: str, *, color_system: 'Optional[Literal["auto", "standard", "256", "truecolor", "windows"]]' ) -> None: expected_output = textwrap.dedent(expected) stream = StringIO() console = rich.console.Console(file=stream, color_system=color_system) console.print(error) assert stream.getvalue() == expected_output class TestDiagnosticPipErrorPresentation: def test_complete_string(self) -> None: # GIVEN error = DiagnosticError( reference="ooops-an-error-occured", message=( "This is an error message describing the issues." "\nIt can have multiple lines." ), context=( "This is some context associated with that error." "\nAny relevant additional details are mentioned here." ), hint_stmt=( "This is a hint, that will help you figure this out." "\nAnd the hint can have multiple lines." ), note_stmt=( "This is to draw your [b]attention[/] toward about something important." "\nAnd this can also have multiple lines." ), ) # WHEN / THEN assert str(error) == ( "" ) assert repr(error) == ( "" ) def test_complete(self) -> None: assert_presentation_matches( DiagnosticError( reference="ooops-an-error-occured", message=( "This is an error message describing the issues." "\nIt can have multiple lines." ), context=( "This is some context associated with that error." "\nAny relevant additional details are mentioned here." ), hint_stmt=( "This is a hint, that will help you figure this out." "\nAnd the hint can have multiple lines." ), note_stmt=( "This is to draw your [b]attention[/] toward about something important." "\nAnd this can also have multiple lines." ), ), """\ error: ooops-an-error-occured × This is an error message describing the issues. │ It can have multiple lines. ╰─> This is some context associated with that error. Any relevant additional details are mentioned here. note: This is to draw your attention toward about something important. And this can also have multiple lines. hint: This is a hint, that will help you figure this out. And the hint can have multiple lines. link: https://sphinx-theme-builder.rtfd.io/errors/#ooops-an-error-occured """, color_system=None, ) def test_complete_colors(self) -> None: assert_presentation_matches( DiagnosticError( reference="ooops-an-error-occured", message=( "This is an error message describing the issues." "\nIt can have multiple lines." ), context=( "This is some context associated with that error." "\nAny relevant additional details are mentioned here." ), hint_stmt=rich.text.Text( "This is a hint, that will help you figure this out." "\nAnd the [b]hint[/] can have multiple lines." ), note_stmt=( "This is to draw your [b]attention[/] toward about something important." "\nAnd this can also have multiple lines." ), ), # Yes, I know this is dumb. """\ \x1b[1;31merror\x1b[0m: \x1b[1mooops-an-error-occured\x1b[0m \x1b[31m×\x1b[0m This is an error message describing the issues. \x1b[31m│\x1b[0m It can have multiple lines. \x1b[31m╰─>\x1b[0m This is some context associated with that error. \x1b[31m \x1b[0m Any relevant additional details are mentioned here. \x1b[1;35mnote\x1b[0m: This is to draw your \x1b[1mattention\x1b[0m toward about something important. And this can also have multiple lines. \x1b[1;36mhint\x1b[0m: This is a hint, that will help you figure this out. And the [b]hint[/] can have multiple lines. \x1b[1mlink\x1b[0m: \x1b[4;94mhttps://sphinx-theme-builder.rtfd.io/errors/#ooops-an-error-occured\x1b[0m """, color_system="256", ) def test_no_note_no_hint_no_context(self) -> None: # GIVEN assert_presentation_matches( DiagnosticError( reference="ooops-an-error-occured", message=( "This is an error message describing the issues." "\nIt can have multiple lines." ), context=None, hint_stmt=None, ), """\ error: ooops-an-error-occured × This is an error message describing the issues. It can have multiple lines. link: https://sphinx-theme-builder.rtfd.io/errors/#ooops-an-error-occured """, color_system=None, ) def test_no_note_no_hint(self) -> None: # GIVEN assert_presentation_matches( DiagnosticError( reference="ooops-an-error-occured", message=( "This is an error message describing the issues." "\nIt can have multiple lines." ), context=( "This is some context associated with that error." "\nAny relevant additional details are mentioned here." ), hint_stmt=None, ), """\ error: ooops-an-error-occured × This is an error message describing the issues. │ It can have multiple lines. ╰─> This is some context associated with that error. Any relevant additional details are mentioned here. link: https://sphinx-theme-builder.rtfd.io/errors/#ooops-an-error-occured """, color_system=None, ) def test_no_note(self) -> None: # GIVEN assert_presentation_matches( DiagnosticError( reference="ooops-an-error-occured", message=( "This is an error message describing the issues." "\nIt can have multiple lines." ), context=( "This is some context associated with that error." "\nAny relevant additional details are mentioned here." ), hint_stmt=( "This is a hint, that will help you figure this out." "\nAnd the hint can have multiple lines." ), ), """\ error: ooops-an-error-occured × This is an error message describing the issues. │ It can have multiple lines. ╰─> This is some context associated with that error. Any relevant additional details are mentioned here. hint: This is a hint, that will help you figure this out. And the hint can have multiple lines. link: https://sphinx-theme-builder.rtfd.io/errors/#ooops-an-error-occured """, color_system=None, ) def test_no_hint(self) -> None: # GIVEN assert_presentation_matches( DiagnosticError( reference="ooops-an-error-occured", message=( "This is an error message describing the issues." "\nIt can have multiple lines." ), context=( "This is some context associated with that error." "\nAny relevant additional details are mentioned here." ), hint_stmt=None, note_stmt=( "This is to draw your [b]attention[/] toward about something important." "\nAnd this can also have multiple lines." ), ), """\ error: ooops-an-error-occured × This is an error message describing the issues. │ It can have multiple lines. ╰─> This is some context associated with that error. Any relevant additional details are mentioned here. note: This is to draw your attention toward about something important. And this can also have multiple lines. link: https://sphinx-theme-builder.rtfd.io/errors/#ooops-an-error-occured """, color_system=None, ) sphinx-theme-builder-0.2.0b2/tests/unit/test_project.py000066400000000000000000000337721441026434600232120ustar00rootroot00000000000000import sys import textwrap from pathlib import Path from typing import Any from unittest import mock import pytest from packaging.version import Version from sphinx_theme_builder._internal.project import ( ImproperProjectMetadata, InvalidProjectStructure, Project, read_toml_file, ) @mock.patch(f"{'tomllib' if sys.version_info > (3, 11) else 'tomli'}.load") def test_read_toml_file(patched_load: mock.Mock, tmp_path: Path) -> None: # GIVEN file = tmp_path / "foo.toml" file.write_text("") # WHEN read_toml_file(file) # THEN patched_load.assert_called_once() assert patched_load.call_args[0][0].name == str(file) class TestProjectFromPath: def test_works_on_valid_pyproject(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = 'magic-project' version = '1.0' license = { text = "MIT" } [tool.sphinx-theme-builder] node-version = "16.13.0" """ ) ) (tmp_path / "src" / "magic_project").mkdir(parents=True) (tmp_path / "src" / "magic_project" / "__init__.py").write_text("") # WHEN project = Project.from_path(tmp_path) # THEN assert project.snake_name == "magic_project" assert project.kebab_name == "magic-project" assert project.location == tmp_path assert project.metadata assert project.metadata.version == Version("1.0") assert project.python_package_path == tmp_path / "src" / "magic_project" assert project.theme_path == ( tmp_path / "src" / "magic_project" / "theme" / "magic-project" ) assert project.theme_conf_path == ( tmp_path / "src" / "magic_project" / "theme" / "magic-project" / "theme.conf" ) assert project.theme_static_path == ( tmp_path / "src" / "magic_project" / "theme" / "magic-project" / "static" ) assert project.output_script_path == ( project.theme_static_path / "scripts" / "magic-project.js" ) assert project.output_stylesheet_path == ( project.theme_static_path / "styles" / "magic-project.css" ) assert project.assets_path == tmp_path / "src" / "magic_project" / "assets" assert project.input_stylesheets_path == ( tmp_path / "src" / "magic_project" / "assets" / "styles" ) assert project.input_scripts_path == ( tmp_path / "src" / "magic_project" / "assets" / "scripts" ) def test_rejects_without_pyproject_toml(self, tmp_path: Path) -> None: # GIVEN # an empty directory # WHEN with pytest.raises(InvalidProjectStructure) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Could not find a `pyproject.toml`" in error.message assert error.context assert str(tmp_path) in error.context assert error.reference == "pyproject-missing" def test_rejects_on_invalid_pyproject_toml(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text("key = value") # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Could not parse `pyproject.toml`" in error.message assert error.context assert str(tmp_path) in error.context assert "pyproject.toml" in error.context assert "line 1, column 7" in error.context assert error.reference == "pyproject-could-not-parse" def test_rejects_without_project_table(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text("key = 1") # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Could not find [project] table" in error.message assert error.context assert str(tmp_path) in error.context assert "pyproject.toml" in error.context assert error.reference == "pyproject-no-project-table" def test_rejects_without_name(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text("[project]\nkey = 1") # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Could not find `name`" in error.message assert "[project] table" in error.message assert error.context assert "pyproject.toml" in error.context assert error.reference == "pyproject-no-name-in-project-table" def test_rejects_non_canonical_names(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text("[project]\nname = 'MAGIC'") # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert ( "Found non-canonical `name` declared in the [project] table" in error.message ) assert error.context assert "pyproject.toml" in error.context assert error.reference == "pyproject-non-canonical-name" def test_works_with_proper_static_version(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" version = "0.1.2" license = { text = "MIT" } [tool.sphinx-theme-builder] node-version = "16.13.0" """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text("") # WHEN project = Project.from_path(tmp_path) # THEN assert project.metadata.version == Version("0.1.2") def test_works_with_proper_dynamic_version(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" dynamic = ["version"] license = { text = "MIT" } [tool.sphinx-theme-builder] node-version = "16.13.0" """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text( textwrap.dedent( """ version = "2.3.4" # not really, we just ignore this. __version__ = "1.2.3" """ ) ) # WHEN project = Project.from_path(tmp_path) # THEN assert project.metadata.version == Version("1.2.3") def test_rejects_when_no_version_is_declared(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text("") # WHEN with pytest.raises(InvalidProjectStructure) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "No version declaration found for project." in error.message assert error.context assert "__init__.py" in error.context assert "pyproject.toml" in error.context assert error.hint_stmt assert "forget" in error.hint_stmt assert "?" in error.hint_stmt assert error.reference == "project-no-version-declaration" def test_rejects_when_no_python_file_is_available(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" version = "1.0.0" """ ) ) # WHEN with pytest.raises(InvalidProjectStructure) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "__init__.py is missing" in error.message assert error.context assert "project name is magic" in error.context assert error.reference == "project-init-missing" def test_rejects_with_double_declaration(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" version = "1.2.3" """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text('__version__ = "2.3.4"') # WHEN with pytest.raises(InvalidProjectStructure) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Found version declaration in both" in error.message assert "pyproject.toml" in error.message assert "__init__.py" in error.message assert error.reference == "project-double-version-declaration" def test_rejects_dynamic_with_version_in_pyproject(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" version = "2.3.4" dynamic = ["version"] """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text("") # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "dynamic" in error.message assert "version" in error.message assert "pyproject.toml" in error.message assert error.context assert "2.3.4" in error.context assert error.hint_stmt assert "removing `version`" in error.hint_stmt assert error.reference == "project-improper-dynamic-version" def test_rejects_no_dynamic_with_version_in_python_file( self, tmp_path: Path ) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text('__version__ = "1.2.3"') # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Found version in `__init__.py`" in error.message assert '"version" in `project.dynamic' in error.message assert error.context assert "1.2.3" in error.context assert error.hint_stmt assert error.reference == "project-missing-dynamic-version" @pytest.mark.parametrize("value", [1, 1.0, []]) def test_rejects_non_string_static_versions_toml( self, tmp_path: Path, value: Any ) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( f""" [project] name = "magic" version = {value} """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text("") # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Expected " in error.message assert "a string" in error.message assert error.context assert f"Got {value}" in error.context assert f"{type(value)}" in error.context assert error.reference == "pyproject-invalid-version" def test_rejects_invalid_versions(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" version = "asdf" """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text("") # WHEN with pytest.raises(ImproperProjectMetadata) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "invalid version" in error.message assert "asdf" in error.message assert error.context assert "pyproject.toml" in error.context assert error.reference == "project-invalid-version" def test_rejects_invalid_python_files(self, tmp_path: Path) -> None: # GIVEN (tmp_path / "pyproject.toml").write_text( textwrap.dedent( """ [project] name = "magic" dynamic = ["version"] """ ) ) (tmp_path / "src" / "magic").mkdir(parents=True) (tmp_path / "src" / "magic" / "__init__.py").write_text(")") # WHEN with pytest.raises(InvalidProjectStructure) as ctx: Project.from_path(tmp_path) # THEN error = ctx.value assert "Could not parse" in error.message assert error.context assert "__init__.py" in error.context assert "SyntaxError" in error.context assert error.reference == "project-init-invalid-syntax" sphinx-theme-builder-0.2.0b2/tests/workflow/000077500000000000000000000000001441026434600210125ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/tests/workflow/__init__.py000066400000000000000000000000001441026434600231110ustar00rootroot00000000000000sphinx-theme-builder-0.2.0b2/tests/workflow/test_cli.py000066400000000000000000000013561441026434600231770ustar00rootroot00000000000000"""Overall workflow tests for `stb`.""" from click import Group from click.testing import CliRunner class TestCLIRoot: def test_no_arguments(self, runner: CliRunner, cli: Group) -> None: process = runner.invoke(cli, []) assert process.exit_code == 0 assert process.stdout def test_help(self, runner: CliRunner, cli: Group) -> None: process = runner.invoke(cli, ["--help"]) assert process.exit_code == 0 assert process.stdout def test_no_arguments_behaves_same_as_help( self, runner: CliRunner, cli: Group ) -> None: process_one = runner.invoke(cli, []) process_two = runner.invoke(cli, ["--help"]) assert process_one.stdout == process_two.stdout sphinx-theme-builder-0.2.0b2/tests/workflow/test_new.py000066400000000000000000000013611441026434600232150ustar00rootroot00000000000000"""Overall workflow tests for `stb new`.""" from click import Group from click.testing import CliRunner class TestCLINew: def test_no_arguments(self, runner: CliRunner, cli: Group) -> None: process = runner.invoke(cli, []) assert process.exit_code == 0 assert process.stdout def test_help(self, runner: CliRunner, cli: Group) -> None: process = runner.invoke(cli, ["--help"]) assert process.exit_code == 0 assert process.stdout def test_no_arguments_behaves_same_as_help( self, runner: CliRunner, cli: Group ) -> None: process_one = runner.invoke(cli, []) process_two = runner.invoke(cli, ["--help"]) assert process_one.stdout == process_two.stdout sphinx-theme-builder-0.2.0b2/tests/workflow/test_package.py000066400000000000000000000005321441026434600240160ustar00rootroot00000000000000"""Overall workflow tests for `stb package`.""" from click import Group from click.testing import CliRunner class TestPackageCommand: def test_help(self, runner: CliRunner, cli: Group) -> None: process = runner.invoke(cli, ["package", "--help"]) assert process.exit_code == 0, process.stdout assert process.stdout sphinx-theme-builder-0.2.0b2/tests/workflow/test_serve.py000066400000000000000000000000561441026434600235500ustar00rootroot00000000000000"""Overall workflow tests for `stb serve`."""