diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..1bbf154 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,46 @@ +name: Python application + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install -e . + - name: Pre-commit checks + run: | + python -m pip install pre-commit + pre-commit run --all-files + - name: Run tests + run: | + python -m pytest + # - name: Sphinx documentation build + # run: | + # make docs + # - name: Deploy to GitHub Pages + # uses: peaceiris/actions-gh-pages@v3 + # if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + # with: + # publish_branch: gh-pages + # github_token: ${{ secrets.GITHUB_TOKEN }} + # publish_dir: docs/build/html/ + # force_orphan: true diff --git a/.gitignore b/.gitignore index 2d52d6a..d930761 100644 --- a/.gitignore +++ b/.gitignore @@ -1,102 +1,167 @@ +# autogenerated by sphinx-napoleon +docs/source/modules.rst +docs/source/pytemplate.rst + +### Add custom ignores above this line ### + +## Copied over from https://github.com/github/gitignore/blob/main/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ -.pytest_cache/ *.py[cod] +*$py.class # C extensions *.so # Distribution / packaging .Python -.venv/ -env/ -bin/ build/ develop-eggs/ dist/ +downloads/ eggs/ +.eggs/ lib/ lib64/ parts/ sdist/ var/ - -! include/ -**/include/* -!**/cuquantum_wrapper/extern/**/include/* - -man/ -venv/ +wheels/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec # Installer logs pip-log.txt pip-delete-this-directory.txt -pip-selfcheck.json # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage +.coverage.* .cache nosetests.xml coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ # Translations *.mo - -# Mr Developer -.mr.developer.cfg -.project -.pydevproject - -# Rope -.ropeproject +*.pot # Django stuff: *.log -*.pot +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache -.DS_Store +# Scrapy stuff: +.scrapy # Sphinx documentation docs/_build/ -# PyCharm -.idea/ - -# VSCode -.vscode/ - -# Pyenv -.python-version +# PyBuilder +.pybuilder/ +target/ +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ -# Jupyter -**/.ipynb_checkpoints +# Spyder project settings +.spyderproject +.spyproject -# Cython -*.pyc -*.pyd +# Rope project settings +.ropeproject -# linting -.pylintrc +# mkdocs documentation +/site -~* +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ +# Pyre type checker +.pyre/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +# pytype static type analyzer +.pytype/ -# These are backup files generated by rustfmt -**/*.rs.bk +# Cython debug symbols +cython_debug/ -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb \ No newline at end of file +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b7bd619 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-toml + - id: check-yaml + - id: check-added-large-files + # Python-specific + - id: check-ast + - id: check-docstring-first + - id: debug-statements + + - repo: https://github.com/crate-ci/typos + rev: v1.16.18 + hooks: + - id: typos + args: [] + + - repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black + + - repo: https://github.com/keewis/blackdoc + rev: v0.3.8 + hooks: + - id: blackdoc + additional_dependencies: + - black==23.9.1 + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.292 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.5.1' + hooks: + - id: mypy + pass_filenames: false + args: [--package=phir, --package=tests] + additional_dependencies: [ + pydantic, + pytest, + rich, + types-setuptools, + ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fe5afc7 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: install dev tests lint docs clean build + +install: + pip install . + +dev: + pip install -e . + +tests: + pytest . + +lint: + pre-commit run --all-files + +docs: + sphinx-apidoc -f -o docs/source/ phir + sphinx-build -M html docs/source/ docs/build/ + +clean: + rm -rf *.egg-info dist build docs/build + +build: clean + python -m build --sdist -n diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e98663 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# PHIR: _[PECOS](https://github.com/PECOS-packages/PECOS) High-level Intermediate Representation_ + +See [PHIR specification](./phir_spec_qasm.md) for more. + +## Installation + +Clone the repository and run: + +```sh +python -m venv .venv +source .venv/bin/activate +pip install -U pip setuptools +pip install -r requirements.txt +pre-commit install +``` + +Then install the project using: + +```sh +pip install -e . +``` + +See `Makefile` for other useful commands. + +## Testing + +Just issue `pytest` from the root directory. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..a40eb43 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,37 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. +import pathlib +import sys + +sys.path.insert(0, pathlib.Path(__file__).parents[2].resolve().as_posix()) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "phir" +project_copyright = "2023, Author" +author = "Author" +release = "0.0.1" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.doctest", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.napoleon", +] + +exclude_patterns = [] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "alabaster" diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..60e2610 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,51 @@ +phir +========== + +Demo Sphinx site for the `CQCL phir `_ project. + +---- + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + modules + +Installation +------------ + +To install phir, simply run: + +.. code-block:: bash + + pip install phir + +Usage +----- + +To use phir, import the main module: + +.. code-block:: python + + from phir import main + + main.run() + +Utils +----- + +The utils module contains various utility functions that can be used in conjunction with phir. To use the utils module, import it like so: + +.. code-block:: python + + from phir import utils + + utils.do_something() + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..ac057a5 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,55 @@ +# Global options: + +; See doc at https://mypy.readthedocs.io/en/stable/config_file.html +[mypy] +follow_imports = silent +python_version = 3.10 +plugins = pydantic.mypy + +exclude = (?x)( + ^build/ + ) + +## Disallow dynamic typing +disallow_any_unimported = true +disallow_any_expr = true +disallow_any_decorated = true +disallow_any_generics = true +disallow_subclassing_any = true + +## Untyped definitions and calls +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true + +## Configuring warnings +warn_redundant_casts = true +warn_unused_ignores = true +warn_return_any = true +warn_unreachable = true + +## Miscellaneous strictness flags +local_partial_types = true +implicit_reexport = false +strict_equality = true +strict = true + +## Configuring error messages +; show_error_context = true +pretty = true + +## Miscellaneous +scripts_are_modules = true +warn_unused_configs = true + +# Per-module options: + +[mypy-tests.*] +disallow_untyped_defs = false + +[pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true diff --git a/phir/__init__.py b/phir/__init__.py new file mode 100644 index 0000000..1f33ecc --- /dev/null +++ b/phir/__init__.py @@ -0,0 +1 @@ +"""Init file for phir.""" diff --git a/phir/cli.py b/phir/cli.py new file mode 100644 index 0000000..25ebec6 --- /dev/null +++ b/phir/cli.py @@ -0,0 +1,58 @@ +"""PHIR validation driver.""" + +# mypy: disable-error-code="misc" + +import argparse +import importlib.metadata +import json +import sys +from pathlib import Path + +from pydantic import ValidationError +from rich import print + +from phir.model import PHIRModel + + +def main() -> None: + """CLI Entrypoint.""" + parser = argparse.ArgumentParser( + prog="phir", + description="Validates and pretty prints valid PHIR", + ) + parser.add_argument( + "jsonfile", + nargs="?", + help="json file to validate against PHIR spec", + ) + parser.add_argument( + "-s", + "--schema", + action="store_true", + default=False, + help="dump JSON schema of the PHIR model and exit", + ) + parser.add_argument( + "-v", + "--version", + action="version", + version=f'{importlib.metadata.version("phir")}', + ) + args = parser.parse_args() + + if args.schema: + print(json.dumps(PHIRModel.model_json_schema(), indent=2)) + sys.exit(1) + + if args.jsonfile: + with open(Path(args.jsonfile)) as f: + data = json.load(f) + + try: + print(PHIRModel.model_validate(data, strict=True)) + except ValidationError as e: + print(e) + + if len(sys.argv) == 1: + parser.print_help(sys.stderr) + sys.exit(1) diff --git a/phir/model.py b/phir/model.py new file mode 100644 index 0000000..c680101 --- /dev/null +++ b/phir/model.py @@ -0,0 +1,138 @@ +"""PHIR model lives here.""" + +from __future__ import annotations + +from typing import Any, Literal, TypeAlias + +from pydantic import BaseModel + +# Data Management + + +class Data(BaseModel): + """Data Management Base Class.""" + + metadata: dict[str, Any] | None = None + + +class VarDefine(Data): + """Defining Variables.""" + + data_type: str | type + data: str + variable: str + size: int + + +class CVarDefine(VarDefine): + """Defining Classical Variables.""" + + data: Literal["cvar_define"] + + +class QVarDefine(VarDefine): + """Defining Quantum Variables.""" + + data: Literal["qvar_define"] + + +class ExportVar(Data): + """Exporting Classical Variables.""" + + variables: list[str] + to: list[str] | None = None + + +DataMgmt: TypeAlias = CVarDefine | QVarDefine | ExportVar + +# Operations + + +class Op(BaseModel): + """Operation Base Class.""" + + args: list[Any] | None = None + returns: list[Any] | None = None + metadata: dict[str, Any] | None = None + + +class QOp(Op): + """Quantum operation.""" + + qop: str + args: list[list[str | int] | list[list[str | int]]] + + +class COp(Op): + """Classical operation.""" + + cop: str + args: list[Any] + + +class FFCall(COp): + """External Classical Function Call.""" + + cop: Literal["ffcall"] + function: str + + +class MOp(Op): + """Machine operation.""" + + mop: str + + +class EMOp(Op): + """Error model operation.""" + + # NOTE: unused + + +class SOp(Op): + """Simulation model.""" + + # NOTE: unused + + +OpType: TypeAlias = FFCall | COp | QOp | MOp + + +# Blocks + + +class Block(BaseModel): + """General block type.""" + + block: str + metadata: dict[str, Any] | None = None + + +class SeqBlock(Block): + """A generic sequence block.""" + + block: Literal["sequence"] + + ops: list[OpType | BlockType] + + +class IfBlock(Block): + """If/else block.""" + + block: Literal["if"] + + condition: COp + true_branch: list[OpType] + false_branch: list[OpType] | None = None + + +BlockType: TypeAlias = SeqBlock | IfBlock + + +class PHIRModel(BaseModel): + """PHIR model object.""" + + format_: str = "PHIR/JSON" + version: str = "0.1.0" + metadata: dict[str, Any] | None = None + ops: list[DataMgmt | OpType | BlockType] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..03999fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "phir" +version = "0.0.1" +description = "A data model and validation tool for PHIR (PECOS High-level Intermediate Representation)." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + {name = "CiarĂ¡n Ryan-Anderson", email = "ciaran.ryan-anderson@quantinuum.com" }, + {name = "Kartik Singhal", email = "kartik.singhal@quantinuum.com" }, +] +dependencies = ["pydantic", "rich"] + +[project.optional-dependencies] +tests = ["pytest"] +docs = ["sphinx", "sphinx-rtd-theme"] + +[project.scripts] +phir = "phir.cli:main" + +[project.urls] +Repository = "https://github.com/CQCL/phir.git" + +[tool.setuptools.packages.find] +where = ["."] + +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a9e38b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +black==23.9.1 +build==1.0.3 +mypy==1.6.0 +pre-commit==3.4.0 +pydantic==2.4.2 +pytest==7.4.2 +rich==13.6.0 +ruff==0.0.292 +sphinx==7.2.6 +wheel==0.41.2 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..1d2caf5 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,60 @@ +# See https://docs.astral.sh/ruff/rules/ +target-version = "py310" + +line-length = 88 + +select = [ + "E", # pycodestyle Errors + "W", # pycodestyle Warnings + + "A", # flake8-builtins + "B", # flake8-Bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "COM", # flake8-commas + "D", # pydocstyle + "EM", # flake8-errmsg + "EXE", # flake8-executable + "F", # pyFlakes + "FA", # flake8-future-annotations + "FIX", # flake8-fixme + "FLY", # flynt + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "N", # pep8-Naming + "NPY", # NumPy-specific + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "RSE", # flake8-raise + "RUF", # Ruff-specific + "S", # flake8-bandit (Security) + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T10", # flake8-debugger + "T20", # flake8-print + "TD", # flake8-todos + "TRY", # tryceratops + "UP", # pyupgrade + "YTT", # flake8-2020 +] + +[per-file-ignores] +"__init__.py" = ["F401"] # module imported but unused +"docs/*" = [ + "D100", # Missing docstring in public module + "INP001", # File * is part of an implicit namespace package. Add an `__init__.py`. +] +"tests/*" = [ + "INP001", + "S101", # Use of `assert` detected + "PLR2004", # Magic value used in comparison, consider replacing * with a constant variable + ] + +[pydocstyle] +convention = "google" diff --git a/tests/example.json b/tests/example.json new file mode 100644 index 0000000..2e55bb6 --- /dev/null +++ b/tests/example.json @@ -0,0 +1,148 @@ +{ + "format": "PHIR/JSON", + "version": "0.1.0", + "metadata": { + "program_name": "example_prog", + "description": "Program showing off PHIR", + "num_qubits": 10 + }, + "ops": [ + { + "data": "qvar_define", + "data_type": "qubits", + "variable": "q", + "size": 2 + }, + { + "data": "qvar_define", + "data_type": "qubits", + "variable": "w", + "size": 3 + }, + { + "data": "qvar_define", + "data_type": "qubits", + "variable": "d", + "size": 5 + }, + {"data": "cvar_define", "data_type": "i64", "variable": "m", "size": 2}, + { + "data": "cvar_define", + "data_type": "i64", + "variable": "a", + "size": 32 + }, + { + "data": "cvar_define", + "data_type": "i64", + "variable": "b", + "size": 32 + }, + { + "data": "cvar_define", + "data_type": "i64", + "variable": "c", + "size": 12 + }, + { + "data": "cvar_define", + "data_type": "i64", + "variable": "d", + "size": 10 + }, + { + "data": "cvar_define", + "data_type": "i64", + "variable": "e", + "size": 30 + }, + {"data": "cvar_define", "data_type": "i64", "variable": "f", "size": 5}, + { + "data": "cvar_define", + "data_type": "i64", + "variable": "g", + "size": 32 + }, + {"qop": "H", "args": [["q", 0]]}, + {"qop": "CX", "args": [[["q", 0], ["q", 1]]]}, + { + "qop": "Measure", + "args": [["q", 0], ["q", 1]], + "returns": [["m", 0], ["m", 1]] + }, + {"cop": "=", "args": [5], "returns": ["b"]}, + {"cop": "=", "args": [3], "returns": ["c"]}, + { + "cop": "ffcall", + "function": "add", + "args": ["b", "c"], + "returns": [["a", 0]] + }, + { + "block": "if", + "condition": {"cop": "==", "args": ["m", 1]}, + "true_branch": [ + { + "cop": "=", + "args": [ + { + "cop": "|", + "args": [ + {"cop": "^", "args": [["c", 2], "d"]}, + { + "cop": "+", + "args": [ + {"cop": "-", "args": ["e", 2]}, + {"cop": "&", "args": ["f", "g"]} + ] + } + ] + } + ], + "returns": ["a"] + } + ] + }, + { + "block": "if", + "condition": {"cop": "==", "args": ["m", 2]}, + "true_branch": [ + {"cop": "ffcall", "function": "sub", "args": ["d", "e"]} + ] + }, + { + "block": "if", + "condition": {"cop": ">", "args": ["a", 2]}, + "true_branch": [ + {"cop": "=", "args": ["c"], "returns": [7]}, + {"qop": "X", "args": [["w", 0]]}, + {"qop": "H", "args": [["w", 1]]}, + {"qop": "CX", "args": [[["w", 1], ["w", 2]]]}, + { + "qop": "Measure", + "args": [["w", 1], ["w", 2]], + "returns": [["g", 0], ["g", 1]] + } + ] + }, + { + "block": "if", + "condition": {"cop": "==", "args": [["a", 3], 1]}, + "true_branch": [ + { + "qop": "H", + "args": [["d", 0], ["d", 1], ["d", 2], ["d", 3], ["d", 4]] + } + ] + }, + { + "qop": "Measure", + "args": [["d", 0], ["d", 1], ["d", 2], ["d", 3], ["d", 4]], + "returns": [["f", 0], ["f", 1], ["f", 2], ["f", 3], ["f", 4]] + }, + { + "data": "cvar_export", + "variables": ["m", "a", "b", "c", "d", "e", "f", "g"] + } + ] +} diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..935d0e1 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,13 @@ +"""Basic validation tests.""" + +import json + +from phir.model import PHIRModel + + +def test_spec_example(): # noqa: D103 + # From https://github.com/CQCL/phir/blob/main/phir_spec_qasm.md#overall-phir-example-with-quantinuums-extended-openqasm-20 + with open("tests/example.json") as f: + data = json.load(f) # type: ignore [misc] + + PHIRModel.model_validate(data) # type: ignore [misc]