├── .editorconfig ├── .gitattributes ├── .github ├── RELEASE_CHECKLIST.md └── workflows │ ├── build.yml │ ├── docs.yml │ ├── draft-pdf.yml │ ├── lint.yml │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .python-version ├── .vscode ├── extensions.json └── settings.json ├── CITATION.cff ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── automata ├── __init__.py ├── base │ ├── __init__.py │ ├── automaton.py │ ├── config.py │ ├── exceptions.py │ └── utils.py ├── fa │ ├── __init__.py │ ├── dfa.py │ ├── fa.py │ ├── gnfa.py │ └── nfa.py ├── pda │ ├── __init__.py │ ├── configuration.py │ ├── dpda.py │ ├── exceptions.py │ ├── npda.py │ ├── pda.py │ └── stack.py ├── py.typed ├── regex │ ├── __init__.py │ ├── lexer.py │ ├── parser.py │ ├── postfix.py │ └── regex.py └── tm │ ├── __init__.py │ ├── configuration.py │ ├── dtm.py │ ├── exceptions.py │ ├── mntm.py │ ├── ntm.py │ ├── tape.py │ ├── tm.py │ └── tools.py ├── docs ├── api │ ├── base-exception-classes.md │ ├── class-automaton.md │ ├── fa │ │ ├── class-dfa.md │ │ ├── class-fa.md │ │ ├── class-gnfa.md │ │ └── class-nfa.md │ ├── index.md │ ├── pda │ │ ├── class-dpda.md │ │ ├── class-npda.md │ │ └── class-pda.md │ ├── regular-expressions.md │ └── tm │ │ ├── class-dtm.md │ │ ├── class-mntm.md │ │ ├── class-ntm.md │ │ ├── class-tm.md │ │ └── tm-exception-classes.md ├── assets │ └── img │ │ ├── favicon.png │ │ └── logo.svg ├── characteristics.md ├── examples │ ├── fa-examples.md │ ├── img │ │ ├── my_dfa.svg │ │ └── my_nfa.svg │ └── perf-examples.md ├── index.md ├── migration.md └── people.md ├── example_notebooks └── DFA.ipynb ├── joss ├── finite_language_dfa.png ├── paper.bib └── paper.md ├── mkdocs.yml ├── pyproject.toml ├── requirements.docs.txt ├── requirements.txt └── tests ├── __init__.py ├── test_automaton.py ├── test_config.py ├── test_dfa.py ├── test_dpda.py ├── test_dtm.py ├── test_fa.py ├── test_gnfa.py ├── test_lexer.py ├── test_mntm.py ├── test_nfa.py ├── test_npda.py ├── test_ntm.py ├── test_pda.py ├── test_pdaconfiguration.py ├── test_pdastack.py ├── test_postfix.py ├── test_regex.py ├── test_serialization.py ├── test_tm.py ├── test_tmconfiguration.py ├── test_tmtape.py └── test_tmtools.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig (http://editorconfig.org) 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.{json,yml}] 14 | indent_size = 2 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.py diff=python 2 | -------------------------------------------------------------------------------- /.github/RELEASE_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | # Automata Release Checklist 2 | 3 | If you are an Automata collaborator, this is a checklist you can follow to 4 | properly publish a release to GitHub and PyPI. 5 | 6 | - [ ] Before Release 7 | - [ ] Checkout `develop` branch and pull 8 | - [ ] Run tests and coverage report (`coverage run -m nose2`) 9 | - [ ] All tests pass 10 | - [ ] Code coverage is over 90% for all files 11 | - [ ] Update Migration Guide with details of any breaking API changes and 12 | upgrade path, if applicable 13 | - [ ] Update README and Migration Guide with latest major release (e.g. v8), 14 | if applicable 15 | - [ ] Write release notes for new release 16 | - [ ] Check copyright line break in README (there should be two spaces after 17 | the *Copyright \ Caleb Evans* line; sometimes these can get removed 18 | while editing the README, depending on your editor's settings 19 | - [ ] Check copyright year (the end year in the range should always be the 20 | current year) 21 | - [ ] Release 22 | - [ ] Merge `develop` into `main` 23 | - [ ] `git checkout main` 24 | - [ ] `git pull` 25 | - [ ] `git merge develop` 26 | - [ ] Commit version bump in `pyproject.toml` 27 | - [ ] Commit message must be `Prepare v release` (e.g. `Prepare v8.0.0 release`) 28 | - [ ] Tag commit with new release number 29 | - [ ] Tag name must be v-prefixed, followed by the semantic version (e.g. 30 | `v8.0.0`) 31 | - [ ] Push new commit and tag with `git push && git push --tags` 32 | - [ ] Post-Release 33 | - [ ] Check [package page on PyPI](https://pypi.org/project/automata-lib/) to 34 | ensure that new release is public 35 | - [ ] Post new GitHub Release with release notes 36 | - [ ] Rebase `develop` on top of latest `main` 37 | - `git checkout develop` 38 | - `git pull` 39 | - `git rebase main` 40 | - `git push` 41 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build the Python project and build a wheel of the current 2 | # release (*without* publishing to PyPI). For more information see: 3 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 4 | 5 | name: build 6 | 7 | on: 8 | push: 9 | branches: ["*"] 10 | pull_request: 11 | branches: ["*"] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | with: 25 | submodules: recursive 26 | - name: Install Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v3 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Setup Graphviz 32 | uses: ts-graphviz/setup-graphviz@v1 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | python -m pip install -r requirements.txt 38 | 39 | - name: Install pypa/build 40 | run: python -m pip install build --user 41 | 42 | - name: Build a binary wheel and a source tarball 43 | run: python -m build --sdist --wheel --outdir dist/ . 44 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | # Based on https://squidfunk.github.io/mkdocs-material/publishing-your-site/ 3 | on: 4 | push: 5 | branches: 6 | - main 7 | permissions: 8 | contents: write 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Configure Git Credentials 15 | run: | 16 | git config user.name github-actions[bot] 17 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: 3.x 21 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 22 | - uses: actions/cache@v3 23 | with: 24 | key: mkdocs-material-${{ env.cache_id }} 25 | path: .cache 26 | restore-keys: | 27 | mkdocs-material- 28 | - run: pip install -r requirements.docs.txt 29 | - run: mkdocs gh-deploy --force 30 | -------------------------------------------------------------------------------- /.github/workflows/draft-pdf.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | paths: ["joss/**"] 4 | pull_request: 5 | paths: ["joss/**"] 6 | 7 | jobs: 8 | paper: 9 | runs-on: ubuntu-latest 10 | name: Paper Draft 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Build draft PDF 15 | uses: openjournals/openjournals-draft-action@master 16 | with: 17 | journal: joss 18 | # This should be the path to the paper within your repo. 19 | paper-path: joss/paper.md 20 | - name: Upload 21 | uses: actions/upload-artifact@v4 22 | with: 23 | name: paper 24 | # This is the output path where Pandoc will write the compiled 25 | # PDF. Note, this should be the same directory as the input 26 | # paper.md 27 | path: joss/paper.pdf 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install project dependencies and run linter 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: lint 5 | 6 | on: 7 | push: 8 | branches: ["*"] 9 | pull_request: 10 | branches: ["*"] 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | submodules: recursive 25 | - name: Install Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Setup Graphviz 31 | uses: ts-graphviz/setup-graphviz@v1 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install -r requirements.txt 37 | 38 | - name: Run mypy 39 | run: mypy automata/**/*.py tests/*.py 40 | 41 | - name: Run ruff 42 | run: | 43 | ruff check . 44 | ruff format --check . 45 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build the Python project and publish the current tagged 2 | # release to PyPI; it will only run if the triggering commit has been tagged 3 | # For more information see: 4 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 5 | 6 | name: publish 7 | 8 | on: 9 | push: 10 | tags: ["*"] 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | environment: release 16 | # Required for PyPI Trusted Publishers feature 17 | permissions: 18 | id-token: write 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | submodules: recursive 24 | 25 | - name: Install Python 3 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: "3.13" 29 | 30 | - name: Setup Graphviz 31 | uses: ts-graphviz/setup-graphviz@v1 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | python -m pip install -r requirements.txt 37 | 38 | - name: Install pypa/build 39 | run: python -m pip install build --user 40 | 41 | - name: Build a binary wheel and a source tarball 42 | run: python -m build --sdist --wheel --outdir dist/ . 43 | 44 | - name: Publish distribution to Test PyPI 45 | uses: pypa/gh-action-pypi-publish@release/v1 46 | with: 47 | repository_url: https://test.pypi.org/legacy/ 48 | # The pypa/gh-action-pypi-publish action sources attestations from the 49 | # same source, so leaving attestations enabled (the default behavior) 50 | # for both steps will cause the production PyPI step to fail; however, 51 | # disabling attestations on the test PyPI step should allow the 52 | # production PyPI step to succeed 53 | attestations: false 54 | 55 | - name: Publish distribution to PyPI 56 | uses: pypa/gh-action-pypi-publish@release/v1 57 | with: 58 | repository_url: https://upload.pypi.org/legacy/ 59 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install project dependencies and run all tests 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | push: 8 | branches: ["*"] 9 | pull_request: 10 | branches: ["*"] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | submodules: recursive 25 | - name: Install Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Setup Graphviz 31 | uses: ts-graphviz/setup-graphviz@v1 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install -r requirements.txt 37 | 38 | - name: Test with nose2 39 | run: | 40 | coverage run -m nose2 tests 41 | coverage lcov -o cover/coverage.lcov 42 | 43 | - name: Run Coveralls 44 | uses: coverallsapp/github-action@master 45 | continue-on-error: true 46 | with: 47 | path-to-lcov: cover/coverage.lcov 48 | github-token: ${{ secrets.GITHUB_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files generated by OS 2 | .DS_Store 3 | 4 | # Python bytecode 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # Packaging 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | 13 | # Unit test / coverage reports 14 | cover/ 15 | htmlcov/ 16 | .coverage 17 | .noseids 18 | coverage.xml 19 | nosetests.xml 20 | 21 | # Required packages 22 | .virtualenv 23 | .spyproject 24 | 25 | # MyPy 26 | .mypy_cache 27 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff", 4 | "EditorConfig.EditorConfig", 5 | "matangover.mypy", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.extraPaths": [".virtualenv"], 3 | "[python]": { 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "charliermarsh.ruff", 6 | "editor.rulers": [88], 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll": "explicit", 9 | "source.organizeImports": "explicit" 10 | } 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: "1.2.0" 2 | authors: 3 | - family-names: Evans 4 | given-names: Caleb 5 | orcid: "https://orcid.org/0009-0000-8896-6800" 6 | - family-names: Robson 7 | given-names: Eliot W. 8 | orcid: "https://orcid.org/0000-0002-1476-6715" 9 | contact: 10 | - family-names: Robson 11 | given-names: Eliot W. 12 | orcid: "https://orcid.org/0000-0002-1476-6715" 13 | doi: 10.5281/zenodo.8419571 14 | message: If you use this software, please cite our article in the 15 | Journal of Open Source Software. 16 | preferred-citation: 17 | authors: 18 | - family-names: Evans 19 | given-names: Caleb 20 | orcid: "https://orcid.org/0009-0000-8896-6800" 21 | - family-names: Robson 22 | given-names: Eliot W. 23 | orcid: "https://orcid.org/0000-0002-1476-6715" 24 | date-published: 2023-10-09 25 | doi: 10.21105/joss.05759 26 | issn: 2475-9066 27 | issue: 90 28 | journal: Journal of Open Source Software 29 | publisher: 30 | name: Open Journals 31 | start: 5759 32 | title: "automata: A Python package for simulating and manipulating 33 | automata" 34 | type: article 35 | url: "https://joss.theoj.org/papers/10.21105/joss.05759" 36 | volume: 8 37 | title: "automata: A Python package for simulating and manipulating 38 | automata" 39 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | caleb@calebevans.me. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Automata 2 | 3 | ## Code of Conduct 4 | 5 | When interacting with other users and maintainers, please be sure to abide by 6 | the [Code of Conduct](./CODE_OF_CONDUCT.md). 7 | 8 | ## Submitting an issue 9 | 10 | ### Bug reports 11 | 12 | If you are submitting a bug report, please answer the following questions: 13 | 14 | 1. What version of Automata were you using? 15 | 2. What were you doing? 16 | 3. What did you expect to happen? 17 | 4. What happened instead? 18 | 19 | Please provide any code to reproduce the issue, if possible. 20 | 21 | ## New features 22 | 23 | If you are requesting a new feature or change or behavior, please describe what 24 | you are looking for, and what value it will add to your use case. 25 | 26 | ## Modifying the codebase 27 | 28 | Automata is an open-source project under the MIT License, so you are welcome and 29 | encouraged to modify the codebase with new fixes and enhancements. Please 30 | observe the following guidelines when submitting pull requests for new fixes or 31 | features: 32 | 33 | 1. All new code must comply with the enabled ruff lint rules. 34 | If you install Automata with `virtualenv`, the `ruff` 35 | package should be available to you for this purpose with the config in `pyproject.toml`. 36 | The included VSCode configuration is set to run this formatting on save. 37 | 38 | In addition, new code must include type annotations and pass typechecking run with 39 | [mypy](https://mypy.readthedocs.io/en/stable/). 40 | 41 | 2. Whether you are introducing a bug fix or a new feature, you *must* add tests 42 | to verify that your code additions function correctly and break nothing else. 43 | 44 | 3. Please run `coverage run -m nose2 && coverage report` and ensure that your 45 | changes are covered. 46 | 47 | 4. If you are adding a new feature or changing behavior, please 48 | update the documentation appropriately with the relevant information. This 49 | includes updating docstrings for all functions in the public interface, using 50 | the [NumPy style](https://numpydoc.readthedocs.io/en/latest/format.html). To 51 | run the documentation site locally, install the documentation dependencies 52 | with: 53 | 54 | ```sh 55 | pip install -r requirements.dev.txt 56 | ``` 57 | 58 | Then, start the local server with the following command: 59 | 60 | ```sh 61 | mkdocs serve 62 | ``` 63 | 64 | 65 | ### Configuring a virtualenv 66 | 67 | The dependencies for the project and best run inside a `virtualenv`. For 68 | instructions on how to configure virtual environments in Python, please see the 69 | [Virtual Environments](https://docs.python-guide.org/dev/virtualenvs/) 70 | section of the Hitchhiker's Guide to Python. 71 | 72 | ### Installing project dependencies 73 | 74 | You can install all project dependencies via `pip` (assuming your project 75 | virtualenv is active): 76 | 77 | ```sh 78 | pip install -r requirements.txt 79 | ``` 80 | 81 | #### Troubleshooting pygraphviz 82 | 83 | If you run into any trouble building the wheel for `pygraphviz` on macOS when 84 | installing dependencies, try running: 85 | 86 | ```sh 87 | brew install graphviz 88 | python3 -m pip install -U --no-cache-dir \ 89 | --config-settings="--global-option=build_ext" \ 90 | --config-settings="--global-option=-I$(brew --prefix graphviz)/include/" \ 91 | --config-settings="--global-option=-L$(brew --prefix graphviz)/lib/" \ 92 | pygraphviz==1.10 93 | # Proceed to install other dependencies 94 | pip install -r requirements.txt 95 | ``` 96 | 97 | ### Running unit tests 98 | 99 | The project's unit tests are written using [unittest][unittest] and run using 100 | the [nose2][nose2] Python package. You can run all unit tests via the `nose2` 101 | command. 102 | 103 | ```sh 104 | nose2 105 | ``` 106 | 107 | [unittest]: https://docs.python.org/3/library/unittest.html 108 | [nose2]: https://docs.nose2.io/en/latest/ 109 | 110 | ### Code coverage 111 | 112 | The project currently boasts high code coverage across all source files. New 113 | contributions are expected to maintain this high standard. You can view the 114 | current coverage report via the `coverage` command: 115 | 116 | ```sh 117 | coverage run -m nose2 118 | coverage report 119 | ``` 120 | 121 | If the coverage ever decreases, you can generate and open a detailed HTML view 122 | of the coverage report like so: 123 | 124 | ```sh 125 | coverage html 126 | open htmlcov/index.html 127 | ``` 128 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2025 Caleb Evans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Automata 2 | 3 | *Copyright 2016-2025 Caleb Evans* 4 | *Released under the MIT license* 5 | 6 | [![PyPI version](https://badge.fury.io/py/automata-lib.svg)](https://badge.fury.io/py/automata-lib) 7 | [![tests](https://github.com/caleb531/automata/actions/workflows/tests.yml/badge.svg)](https://github.com/caleb531/automata/actions/workflows/tests.yml) 8 | [![docs](https://github.com/caleb531/automata/actions/workflows/docs.yml/badge.svg)](https://github.com/caleb531/automata/actions/workflows/docs.yml) 9 | [![Coverage Status](https://coveralls.io/repos/caleb531/automata/badge.svg?branch=main)](https://coveralls.io/r/caleb531/automata?branch=main) 10 | [![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) 11 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/automata-lib) 12 | [![status](https://joss.theoj.org/papers/fe4d8521383598038e38bc0c948718af/status.svg)](https://joss.theoj.org/papers/fe4d8521383598038e38bc0c948718af) 13 | [![pyOpenSci](https://tinyurl.com/y22nb8up)](https://github.com/pyOpenSci/software-submission/issues/152) 14 | 15 | Links: 16 | - [**Documentation**](https://caleb531.github.io/automata/) 17 | - [**Examples**](https://caleb531.github.io/automata/examples/fa-examples/) 18 | - [**Example Notebooks**](https://github.com/caleb531/automata/tree/main/example_notebooks) 19 | - [**Migration Guide**](https://caleb531.github.io/automata/migration/) 20 | - [**API**](https://caleb531.github.io/automata/api/) 21 | 22 | Automata is a Python 3 library implementing structures and algorithms for manipulating finite automata, 23 | pushdown automata, and Turing machines. The algorithms have been optimized and are capable of 24 | processing large inputs. Visualization logic has also been implemented. This package is suitable for 25 | both researchers wishing to manipulate automata and for instructors teaching courses on theoretical 26 | computer science. 27 | 28 | The library requires Python 3.9 or newer. 29 | 30 | Huge thanks to [@eliotwrobson][eliotwrobson], [@YtvwlD][YtvwlD], 31 | [@dengl11][dengl11], [@Tagl][Tagl], [@lewiuberg][lewiuberg], 32 | [@CamiloMartinezM][CamiloMartinezM], 33 | [@abhinavsinha‑adrino][abhinavsinha-adrino], 34 | [@EduardoGoulart1][EduardoGoulart1], 35 | [@khoda81][khoda81], and 36 | [@christopher-hampson][christopher-hampson] for their invaluable code contributions to 37 | this project! 🎉 38 | 39 | [eliotwrobson]: https://github.com/eliotwrobson 40 | [YtvwlD]: https://github.com/YtvwlD 41 | [dengl11]: https://github.com/dengl11 42 | [Tagl]: https://github.com/Tagl 43 | [lewiuberg]: https://github.com/lewiuberg 44 | [CamiloMartinezM]: https://github.com/CamiloMartinezM 45 | [abhinavsinha-adrino]: https://github.com/abhinavsinha-adrino 46 | [EduardoGoulart1]: https://github.com/EduardoGoulart1 47 | [khoda81]: https://github.com/khoda81 48 | [christopher-hampson]: https://github.com/christopher-hampson 49 | 50 | ## Installing 51 | 52 | You can install the latest version of Automata via pip: 53 | 54 | ```sh 55 | pip install automata-lib 56 | ``` 57 | 58 | To install the optional visual dependencies, use the `visual` extra: 59 | 60 | ```sh 61 | pip install 'automata-lib[visual]' 62 | ``` 63 | 64 | If you encounter errors building `pygraphviz`, you may need to install `graphviz`. 65 | See the instructions [here](https://graphviz.org/download/). 66 | 67 | ## Contributing 68 | 69 | Contributions are always welcome! Take a look at the [contributing guide](./CONTRIBUTING.md). 70 | -------------------------------------------------------------------------------- /automata/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/automata/__init__.py -------------------------------------------------------------------------------- /automata/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/automata/base/__init__.py -------------------------------------------------------------------------------- /automata/base/config.py: -------------------------------------------------------------------------------- 1 | """Global configuration for the library and its primitives""" 2 | 3 | should_validate_automata: bool = True 4 | # When set to True, it disables the freeze_value step 5 | # -> You must guarantee that your code does not modify the automata 6 | allow_mutable_automata: bool = False 7 | -------------------------------------------------------------------------------- /automata/base/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exception classes shared by all automata.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | class AutomatonException(Exception): 7 | """The base class for all automaton-related errors.""" 8 | 9 | pass 10 | 11 | 12 | class InvalidStateError(AutomatonException): 13 | """A state is not a valid state for this automaton.""" 14 | 15 | pass 16 | 17 | 18 | class InvalidSymbolError(AutomatonException): 19 | """A symbol is not a valid symbol for this automaton.""" 20 | 21 | pass 22 | 23 | 24 | class MissingStateError(AutomatonException): 25 | """A state is missing from the automaton definition.""" 26 | 27 | pass 28 | 29 | 30 | class MissingSymbolError(AutomatonException): 31 | """A symbol is missing from the automaton definition.""" 32 | 33 | pass 34 | 35 | 36 | class InitialStateError(AutomatonException): 37 | """The initial state fails to meet some required condition.""" 38 | 39 | pass 40 | 41 | 42 | class FinalStateError(AutomatonException): 43 | """A final state fails to meet some required condition.""" 44 | 45 | pass 46 | 47 | 48 | class RejectionException(AutomatonException): 49 | """The input was rejected by the automaton.""" 50 | 51 | pass 52 | 53 | 54 | class RegexException(Exception): 55 | """The base class for all regular expression related errors""" 56 | 57 | pass 58 | 59 | 60 | @dataclass 61 | class LexerError(RegexException): 62 | """An exception raised for issues in lexing""" 63 | 64 | message: str 65 | position: int 66 | 67 | 68 | class InvalidRegexError(RegexException): 69 | """Regular expression is invalid""" 70 | 71 | pass 72 | 73 | 74 | class SymbolMismatchError(AutomatonException): 75 | """The input symbols between the given automata do not match""" 76 | 77 | pass 78 | 79 | 80 | class EmptyLanguageException(AutomatonException): 81 | """The operation cannot be performed because the language is empty""" 82 | 83 | pass 84 | 85 | 86 | class InfiniteLanguageException(AutomatonException): 87 | """The operation cannot be performed because the language is infinite""" 88 | 89 | pass 90 | 91 | 92 | class DiagramException(AutomatonException): 93 | """The diagram cannot be produced""" 94 | 95 | pass 96 | -------------------------------------------------------------------------------- /automata/base/utils.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous utility functions and classes.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import pathlib 7 | import random 8 | import uuid 9 | from collections import defaultdict, deque 10 | from itertools import count, tee, zip_longest 11 | from typing import ( 12 | Any, 13 | Callable, 14 | Dict, 15 | Generic, 16 | Iterable, 17 | List, 18 | Literal, 19 | Set, 20 | Tuple, 21 | TypeVar, 22 | Union, 23 | ) 24 | 25 | import networkx as nx 26 | from frozendict import frozendict 27 | 28 | # Optional imports for use with visual functionality 29 | try: 30 | import pygraphviz as pgv 31 | except ImportError: 32 | _visual_imports = False 33 | else: 34 | _visual_imports = True 35 | 36 | 37 | LayoutMethod = Literal["neato", "dot", "twopi", "circo", "fdp", "nop"] 38 | 39 | 40 | def freeze_value(value: Any) -> Any: 41 | """ 42 | A helper function to convert the given value / structure into a fully 43 | immutable one by recursively processing said structure and any of its 44 | members, freezing them as well 45 | """ 46 | if isinstance(value, (str, int)): 47 | return value 48 | if isinstance(value, dict): 49 | return frozendict( 50 | { 51 | dict_key: freeze_value(dict_value) 52 | for dict_key, dict_value in value.items() 53 | } 54 | ) 55 | if isinstance(value, set): 56 | return frozenset(freeze_value(element) for element in value) 57 | if isinstance(value, list): 58 | return tuple(freeze_value(element) for element in value) 59 | return value 60 | 61 | 62 | def get_renaming_function(counter: count) -> Callable[[Any], int]: 63 | """ 64 | A helper function that returns a renaming function to be used in the creation of 65 | other automata. The parameter counter should be an itertools count. 66 | This helper function will return the same distinct output taken from counter 67 | for each distinct input. 68 | """ 69 | 70 | return defaultdict(counter.__next__).__getitem__ 71 | 72 | 73 | def create_unique_random_id() -> str: 74 | # To be able to set the random seed, took code from: 75 | # https://nathanielknight.ca/articles/consistent_random_uuids_in_python.html 76 | return str( 77 | uuid.UUID(bytes=bytes(random.getrandbits(8) for _ in range(16)), version=4) 78 | ) 79 | 80 | 81 | def create_graph( 82 | horizontal: bool = True, 83 | reverse_orientation: bool = False, 84 | fig_size: Union[Tuple[float, float], Tuple[float], None] = None, 85 | state_separation: float = 0.5, 86 | ) -> pgv.AGraph: 87 | """Creates and returns a graph object 88 | Args: 89 | - horizontal (bool, optional): Direction of node layout. Defaults 90 | to True. 91 | - reverse_orientation (bool, optional): Reverse direction of node 92 | layout. Defaults to False. 93 | - fig_size (tuple, optional): Figure size. Defaults to None. 94 | - state_separation (float, optional): Node distance. Defaults to 0.5. 95 | Returns: 96 | AGraph with the given configuration. 97 | """ 98 | if not _visual_imports: 99 | raise ImportError( 100 | "Missing visualization packages; please install coloraide and pygraphviz." 101 | ) 102 | 103 | # Defining the graph. 104 | graph = pgv.AGraph(strict=False, directed=True) 105 | 106 | if fig_size is not None: 107 | graph.graph_attr.update(size=", ".join(map(str, fig_size))) 108 | 109 | graph.graph_attr.update(ranksep=str(state_separation)) 110 | 111 | if horizontal: 112 | rankdir = "RL" if reverse_orientation else "LR" 113 | else: 114 | rankdir = "BT" if reverse_orientation else "TB" 115 | 116 | graph.graph_attr.update(rankdir=rankdir) 117 | 118 | return graph 119 | 120 | 121 | def save_graph( 122 | graph: pgv.AGraph, 123 | path: Union[str, os.PathLike], 124 | ) -> None: 125 | """Write `graph` to file given by `path`. PNG, SVG, etc. 126 | Returns the same graph.""" 127 | 128 | save_path_final: pathlib.Path = pathlib.Path(path) 129 | 130 | format = save_path_final.suffix.split(".")[1] if save_path_final.suffix else None 131 | 132 | graph.draw( 133 | path=save_path_final, 134 | format=format, 135 | ) 136 | 137 | 138 | T = TypeVar("T") 139 | 140 | 141 | class PartitionRefinement(Generic[T]): 142 | """Maintain and refine a partition of a set of items into subsets. 143 | Space usage for a partition of n items is O(n), and each refine operation 144 | takes time proportional to the size of its argument. 145 | 146 | Adapted from code by D. Eppstein: 147 | https://www.ics.uci.edu/~eppstein/PADS/PartitionRefinement.py 148 | """ 149 | 150 | __slots__: Tuple[str, ...] = ("_sets", "_partition") 151 | 152 | _sets: Dict[int, Set[T]] 153 | _partition: Dict[T, int] 154 | 155 | def __init__(self, items: Iterable[T]) -> None: 156 | """Create a new partition refinement data structure for the given 157 | items. Initially, all items belong to the same subset. 158 | """ 159 | S = set(items) 160 | self._sets = {id(S): S} 161 | self._partition = {x: id(S) for x in S} 162 | 163 | def get_set_by_id(self, id: int) -> Set[T]: 164 | """Return the set in the partition corresponding to id.""" 165 | return self._sets[id] 166 | 167 | def get_set_ids(self) -> Iterable[int]: 168 | """Return set ids corresponding to the internal partition.""" 169 | return self._sets.keys() 170 | 171 | def get_sets(self) -> Iterable[Set[T]]: 172 | """Return sets corresponding to the internal partition.""" 173 | return self._sets.values() 174 | 175 | def refine(self, S: Iterable[T]) -> List[Tuple[int, int]]: 176 | """Refine each set A in the partition to the two sets 177 | A & S, A - S. Return a list of pairs ids (id(A & S), id(A - S)) 178 | for each changed set. Within each pair, A & S will be 179 | a newly created set, while A - S will be a modified 180 | version of an existing set in the partition (retaining its old id). 181 | Not a generator because we need to perform the partition 182 | even if the caller doesn't iterate through the results. 183 | """ 184 | hit = defaultdict(set) 185 | output = [] 186 | 187 | for x in S: 188 | hit[self._partition[x]].add(x) 189 | 190 | for Aid, AintS in hit.items(): 191 | A = self._sets[Aid] 192 | 193 | # Only need to check lengths, we already know AintS is a subset of A 194 | # by construction 195 | if len(AintS) < len(A): 196 | self._sets[id(AintS)] = AintS 197 | for x in AintS: 198 | self._partition[x] = id(AintS) 199 | A -= AintS 200 | output.append((id(AintS), Aid)) 201 | 202 | return output 203 | 204 | 205 | def pairwise(iterable: Iterable[T], final_none: bool = False) -> Iterable[Tuple[T, T]]: 206 | """Based on https://docs.python.org/3/library/itertools.html#itertools.pairwise""" 207 | a, b = tee(iterable) 208 | next(b, None) 209 | 210 | if final_none: 211 | return zip_longest(a, b) 212 | 213 | return zip(a, b) 214 | 215 | 216 | def get_reachable_nodes( 217 | G: nx.DiGraph, 218 | sources: Iterable[Any], 219 | reversed: bool = False, 220 | ) -> Set[Any]: 221 | """ 222 | Return a set with all descendants (or predecessors if reversed is True) 223 | of the nodes in sources. 224 | 225 | Adapted from: 226 | https://networkx.org/documentation/stable/_modules/networkx/algorithms/traversal/breadth_first_search.html#generic_bfs_edges 227 | """ 228 | seen = set(sources) 229 | get_neighbors = G.predecessors if reversed else G.neighbors 230 | work_deque = deque(sources) 231 | while work_deque: 232 | next_node = work_deque.popleft() 233 | for neighbor in get_neighbors(next_node): 234 | if neighbor in seen: 235 | continue 236 | 237 | seen.add(neighbor) 238 | work_deque.append(neighbor) 239 | 240 | return seen 241 | -------------------------------------------------------------------------------- /automata/fa/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/automata/fa/__init__.py -------------------------------------------------------------------------------- /automata/fa/fa.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with all finite automata.""" 2 | 3 | from __future__ import annotations 4 | 5 | import abc 6 | import os 7 | from collections import defaultdict 8 | from typing import Any, Dict, Generator, List, Optional, Set, Tuple, Union 9 | 10 | from automata.base.automaton import Automaton, AutomatonStateT 11 | from automata.base.utils import ( 12 | LayoutMethod, 13 | create_graph, 14 | create_unique_random_id, 15 | save_graph, 16 | ) 17 | 18 | # Optional imports for use with visual functionality 19 | try: 20 | import coloraide 21 | import pygraphviz as pgv 22 | except ImportError: 23 | _visual_imports = False 24 | else: 25 | _visual_imports = True 26 | 27 | 28 | FAStateT = AutomatonStateT 29 | 30 | 31 | class FA(Automaton, metaclass=abc.ABCMeta): 32 | """ 33 | The `FA` class is an abstract base class from which all finite automata inherit. 34 | Every subclass of FA can be rendered natively inside of a Jupyter notebook 35 | (automatically calling `show_diagram` without any arguments) if installed with 36 | the `visual` optional dependency. 37 | """ 38 | 39 | __slots__ = tuple() 40 | 41 | @staticmethod 42 | def _get_edge_name(symbol: str) -> str: 43 | return "ε" if symbol == "" else str(symbol) 44 | 45 | @abc.abstractmethod 46 | def iter_transitions(self) -> Generator[Tuple[FAStateT, FAStateT, str], None, None]: 47 | """ 48 | Iterate over all transitions in the automaton. Each transition is a tuple 49 | of the form (from_state, to_state, symbol) 50 | """ 51 | 52 | raise NotImplementedError( 53 | f"iter_transitions is not implemented for {self.__class__}" 54 | ) 55 | 56 | def show_diagram( 57 | self, 58 | input_str: Optional[str] = None, 59 | path: Union[str, os.PathLike, None] = None, 60 | *, 61 | layout_method: LayoutMethod = "dot", 62 | horizontal: bool = True, 63 | reverse_orientation: bool = False, 64 | fig_size: Union[Tuple[float, float], Tuple[float], None] = None, 65 | font_size: float = 14.0, 66 | arrow_size: float = 0.85, 67 | state_separation: float = 0.5, 68 | ) -> pgv.AGraph: 69 | """ 70 | Generates a diagram of the associated automaton. 71 | 72 | Parameters 73 | ---------- 74 | input_str : Optional[str], default: None 75 | String consisting of input symbols. If set, will add processing of 76 | the input string to the diagram. 77 | path : Union[str, os.PathLike, None], default: None 78 | Path to output file. If None, the output will not be saved. 79 | horizontal : bool, default: True 80 | Direction of node layout in the output graph. 81 | reverse_orientation : bool, default: False 82 | Reverse direction of node layout in the output graph. 83 | fig_size : Union[Tuple[float, float], Tuple[float], None], default: None 84 | Figure size. 85 | font_size : float, default: 14.0 86 | Font size in the output graph. 87 | arrow_size : float, default: 0.85 88 | Arrow size in the output graph. 89 | state_separation : float, default: 0.5 90 | Distance between nodes in the output graph. 91 | 92 | Returns 93 | ------ 94 | AGraph 95 | A diagram of the given automaton. 96 | """ 97 | 98 | if not _visual_imports: 99 | raise ImportError( 100 | "Missing visualization packages; " 101 | "please install coloraide and pygraphviz." 102 | ) 103 | 104 | # Defining the graph. 105 | graph = create_graph( 106 | horizontal, reverse_orientation, fig_size, state_separation 107 | ) 108 | 109 | font_size_str = str(font_size) 110 | arrow_size_str = str(arrow_size) 111 | 112 | # create unique id to avoid colliding with other states 113 | null_node = create_unique_random_id() 114 | 115 | graph.add_node( 116 | null_node, 117 | label="", 118 | tooltip=".", 119 | shape="point", 120 | fontsize=font_size_str, 121 | ) 122 | initial_node = self._get_state_name(self.initial_state) 123 | graph.add_edge( 124 | null_node, 125 | initial_node, 126 | tooltip="->" + initial_node, 127 | arrowsize=arrow_size_str, 128 | ) 129 | 130 | nonfinal_states = map(self._get_state_name, self.states - self.final_states) 131 | final_states = map(self._get_state_name, self.final_states) 132 | graph.add_nodes_from(nonfinal_states, shape="circle", fontsize=font_size_str) 133 | graph.add_nodes_from(final_states, shape="doublecircle", fontsize=font_size_str) 134 | 135 | is_edge_drawn = defaultdict(lambda: False) 136 | if input_str is not None: 137 | input_path, is_accepted = self._get_input_path(input_str=input_str) 138 | 139 | start_color = coloraide.Color("#ff0") 140 | end_color = ( 141 | coloraide.Color("#0f0") if is_accepted else coloraide.Color("#f00") 142 | ) 143 | interpolation = coloraide.Color.interpolate( 144 | [start_color, end_color], space="srgb" 145 | ) 146 | 147 | # find all transitions in the finite state machine with traversal. 148 | for transition_index, (from_state, to_state, symbol) in enumerate( 149 | input_path, start=1 150 | ): 151 | color = interpolation(transition_index / len(input_path)) 152 | label = self._get_edge_name(symbol) 153 | 154 | is_edge_drawn[from_state, to_state, symbol] = True 155 | graph.add_edge( 156 | self._get_state_name(from_state), 157 | self._get_state_name(to_state), 158 | label=f"<{label} [#{transition_index}]>", 159 | arrowsize=arrow_size_str, 160 | fontsize=font_size_str, 161 | color=color.to_string(hex=True), 162 | penwidth="2.5", 163 | ) 164 | 165 | edge_labels = defaultdict(list) 166 | for from_state, to_state, symbol in self.iter_transitions(): 167 | if is_edge_drawn[from_state, to_state, symbol]: 168 | continue 169 | 170 | from_node = self._get_state_name(from_state) 171 | to_node = self._get_state_name(to_state) 172 | label = self._get_edge_name(symbol) 173 | edge_labels[from_node, to_node].append(label) 174 | 175 | for (from_node, to_node), labels in edge_labels.items(): 176 | graph.add_edge( 177 | from_node, 178 | to_node, 179 | label=",".join(sorted(labels)), 180 | arrowsize=arrow_size_str, 181 | fontsize=font_size_str, 182 | ) 183 | 184 | # Set layout 185 | graph.layout(prog=layout_method) 186 | 187 | # Write diagram to file 188 | if path is not None: 189 | save_graph(graph, path) 190 | 191 | return graph 192 | 193 | @abc.abstractmethod 194 | def _get_input_path( 195 | self, input_str: str 196 | ) -> Tuple[List[Tuple[FAStateT, FAStateT, str]], bool]: 197 | """Calculate the path taken by input.""" 198 | 199 | raise NotImplementedError( 200 | f"_get_input_path is not implemented for {self.__class__}" 201 | ) 202 | 203 | def _repr_mimebundle_( 204 | self, *args: Any, **kwargs: Any 205 | ) -> Dict[str, Union[bytes, str]]: 206 | return self.show_diagram()._repr_mimebundle_(*args, **kwargs) 207 | 208 | @staticmethod 209 | def _add_new_state(state_set: Set[FAStateT], start: int = 0) -> int: 210 | """Adds new state to the state set and returns it""" 211 | new_state = start 212 | while new_state in state_set: 213 | new_state += 1 214 | 215 | state_set.add(new_state) 216 | 217 | return new_state 218 | -------------------------------------------------------------------------------- /automata/pda/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/automata/pda/__init__.py -------------------------------------------------------------------------------- /automata/pda/configuration.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with PDA configurations.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | from automata.base.automaton import AutomatonStateT 7 | from automata.pda.stack import PDAStack 8 | 9 | 10 | @dataclass(frozen=True) 11 | class PDAConfiguration: 12 | """ 13 | A configuration is a triple of current state, remaining input and stack. 14 | 15 | It represents the complete runtime state of a PDA. 16 | It is hashable and immutable. 17 | """ 18 | 19 | __slots__ = ("state", "remaining_input", "stack") 20 | 21 | state: AutomatonStateT 22 | remaining_input: str 23 | stack: PDAStack 24 | 25 | def __repr__(self) -> str: 26 | """Return a string representation of the configuration.""" 27 | return "{}({!r}, {!r}, {!r})".format( 28 | self.__class__.__name__, self.state, self.remaining_input, self.stack 29 | ) 30 | 31 | def __eq__(self, other: Any) -> bool: 32 | """Return True if two PDAConfiguration are equivalent""" 33 | if not isinstance(other, PDAConfiguration): 34 | return NotImplemented 35 | 36 | return ( 37 | self.state == other.state 38 | and self.remaining_input == other.remaining_input 39 | and self.stack == other.stack 40 | ) 41 | -------------------------------------------------------------------------------- /automata/pda/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exception classes specific to pushdown automata.""" 2 | 3 | from automata.base.exceptions import AutomatonException 4 | 5 | 6 | class PDAException(AutomatonException): 7 | """The base class for all PDA-related errors.""" 8 | 9 | pass 10 | 11 | 12 | class NondeterminismError(PDAException): 13 | """A DPDA is exhibiting nondeterminism.""" 14 | 15 | pass 16 | 17 | 18 | class InvalidAcceptanceModeError(PDAException): 19 | """The given acceptance mode is invalid.""" 20 | 21 | pass 22 | -------------------------------------------------------------------------------- /automata/pda/npda.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with nondeterministic pushdown automata.""" 2 | 3 | from typing import AbstractSet, Generator, List, Mapping, Set, Tuple, Union 4 | 5 | import automata.base.exceptions as exceptions 6 | import automata.pda.pda as pda 7 | from automata.base.utils import pairwise 8 | from automata.pda.configuration import PDAConfiguration 9 | from automata.pda.stack import PDAStack 10 | 11 | NPDAStateT = pda.PDAStateT 12 | 13 | NPDAPathT = Mapping[ 14 | str, 15 | Mapping[str, AbstractSet[Tuple[NPDAStateT, Union[str, Tuple[str, ...]]]]], 16 | ] 17 | NPDATransitionsT = Mapping[NPDAStateT, NPDAPathT] 18 | 19 | InputPathListT = List[Tuple[PDAConfiguration, PDAConfiguration]] 20 | 21 | 22 | class NPDA(pda.PDA): 23 | """ 24 | The `NPDA` class is a subclass of `PDA` and represents a nondeterministic 25 | pushdown automaton. 26 | 27 | Parameters 28 | ---------- 29 | states: AbstractSet[NPDAStateT] 30 | A set of the NPDA's valid states 31 | input_symbols: AbstractSet[str] 32 | Set of the NPDA's valid input symbols, each of which is a singleton 33 | string. 34 | stack_symbols: AbstractSet[str] 35 | Set of the NPDA's valid stack symbols, each of which is a singleton 36 | string. 37 | transitions: NPDATransitionsT 38 | A dict consisting of the transitions for each state; see the 39 | example below for the exact syntax 40 | initial_state: NPDAStateT 41 | The name of the initial state for this NPDA. 42 | initial_stack_symbol: str 43 | The name of the initial symbol on the stack for this NPDA. 44 | final_states: AbstractSet[NPDAStateT] 45 | A set of final states for this NPDA. 46 | acceptance_mode: pda.PDAAcceptanceModeT, default: "both" 47 | A string defining whether this NPDA accepts by 48 | `'final_state'`, `'empty_stack'`, or `'both'`. 49 | 50 | Example 51 | ---------- 52 | from automata.pda.npda import NPDA 53 | # NPDA which matches palindromes consisting of 'a's and 'b's 54 | # (accepting by final state) 55 | # q0 reads the first half of the word, q1 the other half, q2 accepts. 56 | # But we have to guess when to switch. 57 | npda = NPDA( 58 | states={'q0', 'q1', 'q2'}, 59 | input_symbols={'a', 'b'}, 60 | stack_symbols={'A', 'B', '#'}, 61 | transitions={ 62 | 'q0': { 63 | '': { 64 | '#': {('q2', '#')}, # no change to stack 65 | }, 66 | 'a': { 67 | '#': {('q0', ('A', '#'))}, # push 'A' to stack 68 | 'A': { 69 | ('q0', ('A', 'A')), # push 'A' to stack 70 | ('q1', ''), # pop from stack 71 | }, 72 | 'B': {('q0', ('A', 'B'))}, # push 'A' to stack 73 | }, 74 | 'b': { 75 | '#': {('q0', ('B', '#'))}, # push 'B' to stack 76 | 'A': {('q0', ('B', 'A'))}, # push 'B' to stack 77 | 'B': { 78 | ('q0', ('B', 'B')), # push 'B' to stack 79 | ('q1', ''), # pop from stack 80 | }, 81 | }, 82 | }, 83 | 'q1': { 84 | '': {'#': {('q2', '#')}}, # push '#' to (currently empty) stack 85 | 'a': {'A': {('q1', '')}}, # pop from stack 86 | 'b': {'B': {('q1', '')}}, # pop from stack 87 | }, 88 | }, 89 | initial_state='q0', 90 | initial_stack_symbol='#', 91 | final_states={'q2'}, 92 | acceptance_mode='final_state' 93 | ) 94 | """ 95 | 96 | __slots__ = ( 97 | "states", 98 | "input_symbols", 99 | "stack_symbols", 100 | "transitions", 101 | "initial_state", 102 | "initial_stack_symbol", 103 | "final_states", 104 | "acceptance_mode", 105 | ) 106 | 107 | def __init__( 108 | self, 109 | *, 110 | states: AbstractSet[NPDAStateT], 111 | input_symbols: AbstractSet[str], 112 | stack_symbols: AbstractSet[str], 113 | transitions: NPDATransitionsT, 114 | initial_state: NPDAStateT, 115 | initial_stack_symbol: str, 116 | final_states: AbstractSet[NPDAStateT], 117 | acceptance_mode: pda.PDAAcceptanceModeT = "both", 118 | ) -> None: 119 | """Initialize a complete NPDA.""" 120 | super().__init__( 121 | states=states, 122 | input_symbols=input_symbols, 123 | stack_symbols=stack_symbols, 124 | transitions=transitions, 125 | initial_state=initial_state, 126 | initial_stack_symbol=initial_stack_symbol, 127 | final_states=final_states, 128 | acceptance_mode=acceptance_mode, 129 | ) 130 | 131 | def iter_transitions( 132 | self, 133 | ) -> Generator[Tuple[NPDAStateT, NPDAStateT, Tuple[str, str, str]], None, None]: 134 | return ( 135 | (from_, to_, (input_symbol, stack_symbol, "".join(stack_push))) 136 | for from_, input_lookup in self.transitions.items() 137 | for input_symbol, stack_lookup in input_lookup.items() 138 | for stack_symbol, op_ in stack_lookup.items() 139 | for (to_, stack_push) in op_ 140 | ) 141 | 142 | def _validate_transition_invalid_symbols( 143 | self, start_state: NPDAStateT, paths: NPDATransitionsT 144 | ) -> None: 145 | """Raise an error if transition symbols are invalid.""" 146 | for input_symbol, symbol_paths in paths.items(): 147 | self._validate_transition_invalid_input_symbols(start_state, input_symbol) 148 | for stack_symbol in symbol_paths: 149 | self._validate_transition_invalid_stack_symbols( 150 | start_state, stack_symbol 151 | ) 152 | 153 | def _get_transitions( 154 | self, state: NPDAStateT, input_symbol: str, stack_symbol: str 155 | ) -> Set[Tuple[str, NPDAStateT, str]]: 156 | """Get the transition tuples for the given state and symbols.""" 157 | transitions = set() 158 | if ( 159 | state in self.transitions 160 | and input_symbol in self.transitions[state] 161 | and stack_symbol in self.transitions[state][input_symbol] 162 | ): 163 | for dest_state, new_stack_top in self.transitions[state][input_symbol][ 164 | stack_symbol 165 | ]: 166 | transitions.add((input_symbol, dest_state, new_stack_top)) 167 | return transitions 168 | 169 | def _get_next_configurations( 170 | self, old_config: PDAConfiguration 171 | ) -> Set[PDAConfiguration]: 172 | """Advance to the next configurations.""" 173 | transitions: Set[Tuple[str, NPDAStateT, str]] = set() 174 | if old_config.remaining_input: 175 | transitions.update( 176 | self._get_transitions( 177 | old_config.state, 178 | old_config.remaining_input[0], 179 | old_config.stack.top(), 180 | ) 181 | ) 182 | transitions.update( 183 | self._get_transitions(old_config.state, "", old_config.stack.top()) 184 | ) 185 | new_configs = set() 186 | for input_symbol, new_state, new_stack_top in transitions: # type: ignore 187 | remaining_input = old_config.remaining_input 188 | if input_symbol: 189 | remaining_input = remaining_input[1:] 190 | new_config = PDAConfiguration( 191 | new_state, 192 | remaining_input, 193 | self._replace_stack_top(old_config.stack, new_stack_top), 194 | ) 195 | new_configs.add(new_config) 196 | return new_configs 197 | 198 | def _get_input_path( 199 | self, input_str: str 200 | ) -> Tuple[List[Tuple[PDAConfiguration, PDAConfiguration]], bool]: 201 | """ 202 | Calculate the path taken by input. 203 | 204 | Args: 205 | input_str (str): The input string to run on the NPDA. 206 | 207 | Returns: 208 | Tuple[List[Tuple[PDAConfiguration, PDAConfiguration]], bool]: A list 209 | of all transitions taken in each step and a boolean indicating 210 | whether the NPDA accepted the input. 211 | 212 | """ 213 | 214 | steps = list(self.read_input_stepwise(input_str)) 215 | 216 | path: List[PDAConfiguration] = [steps.pop().pop()] 217 | 218 | accepted = path[0] in self.final_states 219 | 220 | for step in reversed(steps): 221 | if len(step) == 1: 222 | path.append(step.pop()) 223 | continue 224 | 225 | for curr_step in step: 226 | if path[-1] in self._get_next_configurations(curr_step): 227 | path.append(curr_step) 228 | break 229 | 230 | return list(pairwise(reversed(path))), accepted 231 | 232 | def read_input_stepwise( 233 | self, input_str: str 234 | ) -> Generator[Set[PDAConfiguration], None, None]: 235 | """ 236 | Return a generator that yields the configuration of this NPDA at each 237 | step while reading input. 238 | 239 | Parameters 240 | ---------- 241 | input_str : str 242 | The input string to read. 243 | 244 | Yields 245 | ------ 246 | Generator[Set[PDAConfiguration], None, None] 247 | A generator that yields the current configuration of 248 | the NPDA after each step of reading input. 249 | 250 | Raises 251 | ------ 252 | RejectionException 253 | Raised if this NPDA does not accept the input string. 254 | """ 255 | current_configurations = set() 256 | current_configurations.add( 257 | PDAConfiguration( 258 | self.initial_state, input_str, PDAStack([self.initial_stack_symbol]) 259 | ) 260 | ) 261 | 262 | yield current_configurations 263 | 264 | while current_configurations: 265 | new_configurations = set() 266 | for config in current_configurations: 267 | if self._has_accepted(config): 268 | # One accepting configuration is enough. 269 | return 270 | if config.remaining_input: 271 | new_configurations.update(self._get_next_configurations(config)) 272 | elif self._has_lambda_transition(config.state, config.stack.top()): 273 | new_configurations.update(self._get_next_configurations(config)) 274 | current_configurations = new_configurations 275 | yield current_configurations 276 | 277 | raise exceptions.RejectionException( 278 | "the NPDA did not reach an accepting configuration" 279 | ) 280 | -------------------------------------------------------------------------------- /automata/pda/stack.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with PDA stacks.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import Any, Iterator, Sequence, Tuple 7 | 8 | 9 | @dataclass(frozen=True) 10 | class PDAStack: 11 | """A PDA stack.""" 12 | 13 | __slots__ = ("stack",) 14 | 15 | stack: Tuple[str, ...] 16 | 17 | def __init__(self, stack: Sequence[str]) -> None: 18 | object.__setattr__(self, "stack", tuple(stack)) 19 | 20 | def top(self) -> str: 21 | """ 22 | Return the symbol at the top of the stack. 23 | 24 | Returns 25 | ---------- 26 | str 27 | The symbol at the top of the stack. Returns the empty 28 | string if the stack is empty. 29 | """ 30 | if self.stack: 31 | return self.stack[-1] 32 | 33 | return "" 34 | 35 | def pop(self) -> PDAStack: 36 | """ 37 | Pop the top element from the stack and return 38 | the new stack. 39 | 40 | Returns 41 | ---------- 42 | PDAStack 43 | A copy of the old PDAStack with the top element removed. 44 | """ 45 | return self.__class__(self.stack[:-1]) 46 | 47 | def replace(self, symbols: Sequence[str]) -> PDAStack: 48 | """ 49 | Replace the top of the stack with the given symbols. 50 | 51 | Parameters 52 | ---------- 53 | symbols : Sequence[str] 54 | A sequence of symbols to add to the top of the PDAStack. 55 | The first symbol in the given sequence becomes the new stack top. 56 | 57 | Returns 58 | ---------- 59 | PDAStack 60 | A copy of the old PDAStack with the top element replaced. 61 | """ 62 | return self.__class__(self.stack[:-1] + tuple(reversed(symbols))) 63 | 64 | def __len__(self) -> int: 65 | """Return the number of symbols on the stack.""" 66 | return len(self.stack) 67 | 68 | def __iter__(self) -> Iterator[str]: 69 | """Return an iterator for the stack.""" 70 | return iter(self.stack) 71 | 72 | def __getitem__(self, key: int | slice) -> str | Sequence[str]: 73 | """Return the stack element at the given index""" 74 | return self.stack[key] 75 | 76 | def __reversed__(self) -> Iterator[str]: 77 | """Return an iterator for the stack in reversed order""" 78 | return reversed(self.stack) 79 | 80 | def __repr__(self) -> str: 81 | """Return a string representation of the stack.""" 82 | return "{}({})".format(self.__class__.__name__, self.stack) 83 | 84 | def __eq__(self, other: Any) -> bool: 85 | """Return True if two PDAConfiguration are equivalent""" 86 | if not isinstance(other, PDAStack): 87 | return NotImplemented 88 | 89 | return self.stack == other.stack 90 | -------------------------------------------------------------------------------- /automata/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/automata/py.typed -------------------------------------------------------------------------------- /automata/regex/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/automata/regex/__init__.py -------------------------------------------------------------------------------- /automata/regex/lexer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classes and methods for lexing expressions into lists of tokens. 3 | 4 | Adapted from code in the tdparser library: https://github.com/rbarrois/tdparser 5 | """ 6 | 7 | import abc 8 | import re 9 | from typing import ( 10 | AbstractSet, 11 | Callable, 12 | FrozenSet, 13 | Generator, 14 | Generic, 15 | List, 16 | Optional, 17 | Tuple, 18 | Type, 19 | TypeVar, 20 | ) 21 | 22 | from typing_extensions import Self 23 | 24 | import automata.base.exceptions as exceptions 25 | 26 | ResultT = TypeVar("ResultT") 27 | 28 | 29 | class Token(Generic[ResultT], metaclass=abc.ABCMeta): 30 | """Base class for tokens.""" 31 | 32 | __slots__: Tuple[str, ...] = ("text",) 33 | 34 | text: str 35 | 36 | def __init__(self, text: str) -> None: 37 | self.text = text 38 | 39 | @classmethod 40 | def from_match(cls: Type[Self], match: re.Match) -> Self: 41 | return cls(match.group()) 42 | 43 | def __repr__(self) -> str: 44 | return f"<{self.__class__.__name__}: {self.text}>" 45 | 46 | 47 | TokenFactoryT = Callable[[re.Match], Token[ResultT]] 48 | 49 | 50 | class TokenRegistry(Generic[ResultT]): 51 | """Registry holding token rules.""" 52 | 53 | __slots__: Tuple[str, ...] = ("_tokens",) 54 | 55 | _tokens: List[Tuple[TokenFactoryT, re.Pattern]] 56 | 57 | def __init__(self) -> None: 58 | self._tokens = [] 59 | 60 | def register(self, token_factory_fn: TokenFactoryT, token_regex: str) -> None: 61 | """ 62 | Register a token that can be produced by token_factory_fn (a function 63 | taking in a regex match and returning the final token) and recognized by the 64 | token_regex pattern. 65 | """ 66 | self._tokens.append((token_factory_fn, re.compile(token_regex))) 67 | 68 | def matching_tokens( 69 | self, text: str, start: int 70 | ) -> Generator[Tuple[TokenFactoryT, re.Match], None, None]: 71 | """Retrieve all token definitions matching text starting at start.""" 72 | 73 | for token_factory_fn, regexp in self._tokens: 74 | match = regexp.match(text, pos=start) 75 | if match: 76 | yield (token_factory_fn, match) 77 | 78 | def get_token( 79 | self, text: str, start: int = 0 80 | ) -> Optional[Tuple[TokenFactoryT, re.Match]]: 81 | """ 82 | Retrieve the next token from some text. Computes the best match by 83 | length. Returns None if there is no match in the token registry. 84 | """ 85 | best_token_match = None 86 | best_match = None 87 | 88 | for token_factory_fn, match in self.matching_tokens(text, start): 89 | if not best_match or best_match.end() < match.end(): 90 | best_token_match = (token_factory_fn, match) 91 | best_match = match 92 | 93 | return best_token_match 94 | 95 | def __len__(self) -> int: 96 | return len(self._tokens) 97 | 98 | 99 | class Lexer(Generic[ResultT]): 100 | """ 101 | The core lexer. First, tokens are registered with their factory functions and regex 102 | patterns. The lexer can then take in a string and splits it into a list of token 103 | classes (in infix ordering) matching the regex patterns. 104 | """ 105 | 106 | __slots__: Tuple[str, ...] = ("tokens", "blank_chars") 107 | 108 | tokens: TokenRegistry[ResultT] 109 | blank_chars: FrozenSet[str] 110 | 111 | def __init__(self, blank_chars: Optional[AbstractSet] = None) -> None: 112 | self.tokens = TokenRegistry() 113 | self.blank_chars = ( 114 | frozenset((" ", "\t")) if blank_chars is None else frozenset(blank_chars) 115 | ) 116 | 117 | def register_token(self, token_factory_fn: TokenFactoryT, token_regex: str) -> None: 118 | """ 119 | Register a token class. The token_factory_fn must taken in a 120 | match object and return an instance of the desired token, and token_regex 121 | is used by the lexer to match tokens in the input text. 122 | """ 123 | 124 | self.tokens.register(token_factory_fn, token_regex) 125 | 126 | def lex(self, text: str) -> List[Token[ResultT]]: 127 | """Split text into a list of tokens in infix notation.""" 128 | 129 | pos = 0 130 | res = [] 131 | 132 | while pos < len(text): 133 | token_match = self.tokens.get_token(text, start=pos) 134 | if token_match is not None: 135 | token_factory_fn, match = token_match 136 | res.append(token_factory_fn(match)) 137 | pos += len(match.group()) 138 | elif text[pos] in self.blank_chars: 139 | pos += 1 140 | else: 141 | raise exceptions.LexerError( 142 | f"Invalid character '{text[pos]}' in '{text}'", position=pos 143 | ) 144 | 145 | return res 146 | -------------------------------------------------------------------------------- /automata/regex/postfix.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for converting lists of tokens to postfix ordering.""" 2 | 3 | import abc 4 | from collections import deque 5 | from itertools import chain, zip_longest 6 | from typing import Deque, Iterable, List, Optional, Tuple, TypeVar, cast 7 | 8 | import automata.base.exceptions as exceptions 9 | from automata.regex.lexer import Token 10 | 11 | ExpressionResultT = TypeVar("ExpressionResultT") 12 | 13 | 14 | class Operator(Token[ExpressionResultT]): 15 | """Subclass of token defining an operator.""" 16 | 17 | __slots__: Tuple[str, ...] = tuple() 18 | 19 | @abc.abstractmethod 20 | def get_precedence(self) -> int: 21 | raise NotImplementedError 22 | 23 | 24 | class InfixOperator(Operator[ExpressionResultT]): 25 | """Subclass of operator defining an infix operator.""" 26 | 27 | __slots__: Tuple[str, ...] = tuple() 28 | 29 | @abc.abstractmethod 30 | def op( 31 | self, left: ExpressionResultT, right: ExpressionResultT 32 | ) -> ExpressionResultT: 33 | raise NotImplementedError 34 | 35 | 36 | class PostfixOperator(Operator[ExpressionResultT]): 37 | """Subclass of operator defining an postfix operator.""" 38 | 39 | __slots__: Tuple[str, ...] = tuple() 40 | 41 | @abc.abstractmethod 42 | def op(self, left: ExpressionResultT) -> ExpressionResultT: 43 | raise NotImplementedError 44 | 45 | 46 | class Literal(Token[ExpressionResultT]): 47 | """Subclass of token defining a literal.""" 48 | 49 | __slots__: Tuple[str, ...] = tuple() 50 | 51 | @abc.abstractmethod 52 | def val(self) -> ExpressionResultT: 53 | raise NotImplementedError 54 | 55 | 56 | class RightParen(Token): 57 | """Subclass of token defining a right parenthesis.""" 58 | 59 | __slots__: Tuple[str, ...] = tuple() 60 | 61 | def __repr__(self) -> str: 62 | return "<)>" 63 | 64 | 65 | class LeftParen(Token): 66 | """Subclass of token defining a left parenthesis.""" 67 | 68 | __slots__: Tuple[str, ...] = tuple() 69 | 70 | def __repr__(self) -> str: 71 | return "<(>" 72 | 73 | 74 | def validate_tokens(token_list: List[Token]) -> None: 75 | """Validate the inputted tokens list (in infix ordering).""" 76 | 77 | token_list_prev: Iterable[Optional[Token]] = chain([None], token_list) 78 | 79 | paren_counter = 0 80 | 81 | for prev_token, curr_token in zip_longest(token_list_prev, token_list): 82 | # No postfix or infix operators at the beginning 83 | if prev_token is None and isinstance( 84 | curr_token, (InfixOperator, PostfixOperator) 85 | ): 86 | raise exceptions.InvalidRegexError( 87 | f"'{curr_token}' cannot appear at the start of a statement." 88 | ) 89 | 90 | # No postfix operators at the end of a statement or right before another 91 | # operator or right paren 92 | elif isinstance(prev_token, InfixOperator): 93 | if curr_token is None: 94 | raise exceptions.InvalidRegexError( 95 | f"'{prev_token}' cannot appear at the end of a statement." 96 | ) 97 | elif isinstance(curr_token, (InfixOperator, PostfixOperator, RightParen)): 98 | raise exceptions.InvalidRegexError( 99 | f"'{prev_token}' cannot appear immediately before '{curr_token}'." 100 | ) 101 | 102 | # No left parens right before infix or postfix operators, or right 103 | # before a right paren 104 | elif isinstance(prev_token, LeftParen): 105 | if isinstance(curr_token, (InfixOperator, PostfixOperator)): 106 | raise exceptions.InvalidRegexError( 107 | f"'{prev_token}' cannot appear immediately before '{curr_token}'." 108 | ) 109 | 110 | # Track open/closed parens 111 | paren_counter += 1 112 | 113 | elif isinstance(prev_token, RightParen): 114 | paren_counter -= 1 115 | 116 | if paren_counter < 0: 117 | raise exceptions.InvalidRegexError( 118 | "Token list has mismatched parethesis." 119 | ) 120 | 121 | if paren_counter != 0: 122 | raise exceptions.InvalidRegexError("Token list has unclosed parethesis.") 123 | 124 | 125 | def tokens_to_postfix( 126 | tokens: List[Token[ExpressionResultT]], 127 | ) -> List[Token[ExpressionResultT]]: 128 | """Takes in a list of tokens and changes them to postfix ordering.""" 129 | 130 | stack: Deque[Token[ExpressionResultT]] = deque() 131 | res: List[Token[ExpressionResultT]] = [] 132 | 133 | def comp_precedence( 134 | a: Token[ExpressionResultT], b: Token[ExpressionResultT] 135 | ) -> bool: 136 | """Compare precedence of operators (two tokens).""" 137 | return ( 138 | cast(Operator[ExpressionResultT], a).get_precedence() 139 | <= cast(Operator[ExpressionResultT], b).get_precedence() 140 | ) 141 | 142 | for c in tokens: 143 | if isinstance(c, Literal): 144 | res.append(c) 145 | elif isinstance(c, RightParen): 146 | while len(stack) > 0 and not isinstance(stack[-1], LeftParen): 147 | res.append(stack.pop()) 148 | stack.pop() 149 | elif isinstance(c, LeftParen): 150 | stack.append(c) 151 | elif ( 152 | not stack 153 | or isinstance(stack[-1], LeftParen) 154 | or not comp_precedence(c, stack[-1]) 155 | ): 156 | stack.append(c) 157 | else: 158 | while ( 159 | stack 160 | and not isinstance(stack[-1], LeftParen) 161 | and comp_precedence(c, stack[-1]) 162 | ): 163 | res.append(stack.pop()) 164 | stack.append(c) 165 | 166 | while stack: 167 | res.append(stack.pop()) 168 | 169 | return res 170 | 171 | 172 | def parse_postfix_tokens( 173 | postfix_tokens: List[Token[ExpressionResultT]], 174 | ) -> ExpressionResultT: 175 | """Parse list of postfix tokens to produce value of expression""" 176 | 177 | stack: Deque[ExpressionResultT] = deque() 178 | 179 | for token in postfix_tokens: 180 | if isinstance(token, InfixOperator): 181 | right = stack.pop() 182 | left = stack.pop() 183 | stack.append(token.op(left, right)) 184 | elif isinstance(token, PostfixOperator): 185 | left = stack.pop() 186 | stack.append(token.op(left)) 187 | elif isinstance(token, Literal): 188 | stack.append(token.val()) 189 | else: 190 | raise exceptions.InvalidRegexError(f"Invalid token type {type(token)}") 191 | 192 | return stack[0] 193 | -------------------------------------------------------------------------------- /automata/regex/regex.py: -------------------------------------------------------------------------------- 1 | """ 2 | A set of tools for working with regular expressions. Can recognize regular expressions 3 | over the alphabet of unicode characters and subsets of these, excluding blanks. 4 | 5 | A regular expression with the following operations only are supported in this library: 6 | 7 | - `*`: Kleene star operation, language repeated zero or more times. Ex: `a*`,`(ab)*` 8 | - `+`: Kleene plus operation, language repeated one or more times. Ex: `a+`,`(ab)+` 9 | - `?`: Language repeated zero or one time. Ex: `a?` 10 | - Concatenation. Ex: `abcd` 11 | - `|`: Union. Ex: `a|b` 12 | - `&`: Intersection. Ex: `a&b` 13 | - `.`: Wildcard. Ex: `a.b` 14 | - `^`: Shuffle. Ex: `a^b` 15 | - `{}`: Quantifiers expressing finite repetitions. Ex: `a{1,2}`,`a{3,}` 16 | - `()`: The empty string. 17 | - `(...)`: Grouping. 18 | 19 | This is similar to the Python `re` module, but this library does not support any special 20 | characters other than those given above. All regular languages can be written with 21 | these. 22 | """ 23 | 24 | from itertools import count 25 | from typing import AbstractSet, Optional 26 | 27 | import automata.base.exceptions as exceptions 28 | from automata.fa.nfa import NFA 29 | from automata.regex.parser import RESERVED_CHARACTERS, get_regex_lexer, validate_tokens 30 | 31 | 32 | def _validate(regex: str) -> bool: 33 | """Return True if the regular expression is valid""" 34 | 35 | try: 36 | validate(regex) 37 | except exceptions.InvalidRegexError: 38 | return False 39 | 40 | return True 41 | 42 | 43 | def validate(regex: str) -> None: 44 | """ 45 | Raises an exception if the input regular expression is invalid. 46 | 47 | Raises 48 | ------ 49 | InvalidRegexError 50 | Raised if the regex given as input is not well defined. 51 | """ 52 | input_symbols = set(regex) - RESERVED_CHARACTERS 53 | 54 | validate_tokens(get_regex_lexer(input_symbols, count(0)).lex(regex)) 55 | 56 | 57 | def isequal( 58 | re1: str, re2: str, *, input_symbols: Optional[AbstractSet[str]] = None 59 | ) -> bool: 60 | """ 61 | Whether both regular expressions are equivalent. 62 | 63 | Parameters 64 | ---------- 65 | re1 : str 66 | The first regular expression as a string. 67 | re2 : str 68 | The second regular expression as a string. 69 | input_symbols : Optional[AbstractSet[str]], default: None 70 | The set of input symbols when doing the comparison. Defaults to 71 | all ascii letters and digits. 72 | 73 | Returns 74 | ------ 75 | bool 76 | Whether the regular expressions are equivalent. 77 | """ 78 | 79 | nfa1 = NFA.from_regex(re1, input_symbols=input_symbols) 80 | nfa2 = NFA.from_regex(re2, input_symbols=input_symbols) 81 | 82 | return nfa1 == nfa2 83 | 84 | 85 | def issubset( 86 | re1: str, re2: str, *, input_symbols: Optional[AbstractSet[str]] = None 87 | ) -> bool: 88 | """ 89 | Whether re1 is a subset of re2. 90 | 91 | Parameters 92 | ---------- 93 | re1 : str 94 | The first regular expression as a string. 95 | re2 : str 96 | The second regular expression as a string. 97 | input_symbols : Optional[AbstractSet[str]], default: None 98 | The set of input symbols when doing the comparison. Defaults to 99 | all ascii letters and digits. 100 | 101 | Returns 102 | ------ 103 | bool 104 | True if re1 is a subset of re2. 105 | """ 106 | 107 | nfa1 = NFA.from_regex(re1, input_symbols=input_symbols) 108 | nfa2 = NFA.from_regex(re2, input_symbols=input_symbols) 109 | 110 | return nfa1.union(nfa2) == nfa2 111 | 112 | 113 | def issuperset( 114 | re1: str, re2: str, *, input_symbols: Optional[AbstractSet[str]] = None 115 | ) -> bool: 116 | """ 117 | Whether re1 is a superset of re2. 118 | 119 | Parameters 120 | ---------- 121 | re1 : str 122 | The first regular expression as a string. 123 | re2 : str 124 | The second regular expression as a string. 125 | input_symbols : Optional[AbstractSet[str]], default: None 126 | The set of input symbols when doing the comparison. Defaults to 127 | all ascii letters and digits. 128 | 129 | Returns 130 | ------ 131 | bool 132 | True if re1 is a superset of re2. 133 | """ 134 | 135 | nfa1 = NFA.from_regex(re1, input_symbols=input_symbols) 136 | nfa2 = NFA.from_regex(re2, input_symbols=input_symbols) 137 | 138 | return nfa1.union(nfa2) == nfa1 139 | -------------------------------------------------------------------------------- /automata/tm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/automata/tm/__init__.py -------------------------------------------------------------------------------- /automata/tm/configuration.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with Turing machine configurations.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Tuple 5 | 6 | from automata.tm.tape import TMTape 7 | from automata.tm.tm import TMStateT 8 | 9 | 10 | @dataclass(frozen=True) 11 | class TMConfiguration: 12 | """A Turing machine configuration.""" 13 | 14 | __slots__ = ("state", "tape") 15 | 16 | state: TMStateT 17 | tape: TMTape 18 | 19 | def __repr__(self) -> str: 20 | """Return a string representation of the configuration.""" 21 | return "{}({!r}, {!r})".format(self.__class__.__name__, self.state, self.tape) 22 | 23 | def print(self) -> None: 24 | """Print the machine's current configuration in a readable form.""" 25 | print( 26 | "{current_state}: {tape}\n{current_position}".format( 27 | current_state=self.state, 28 | tape="".join(self.tape).rjust(len(self.tape), self.tape.blank_symbol), 29 | current_position="^".rjust( 30 | self.tape.current_position + len(self.state) + 3 31 | ), 32 | ) 33 | ) 34 | 35 | 36 | @dataclass(frozen=True) 37 | class MTMConfiguration: 38 | """A Multitape Turing machine configuration.""" 39 | 40 | __slots__ = ("state", "tapes") 41 | 42 | state: TMStateT 43 | tapes: Tuple[TMTape, ...] 44 | 45 | def __repr__(self) -> str: 46 | """Return a string representation of the configuration.""" 47 | return "{}({!r}, {!r})".format(self.__class__.__name__, self.state, self.tapes) 48 | 49 | def print(self) -> None: 50 | """Print the machine's current configuration in a readable form.""" 51 | description = "{}: \n".format(self.state) 52 | for i, tape in enumerate(self.tapes, start=1): 53 | title = "> Tape {}: ".format(i) 54 | position = tape.current_position + len(title) + 1 55 | description += "> Tape {j}: {tape}\n{current_position}\n".format( 56 | j=i, 57 | tape="".join(tape).ljust(tape.current_position, "#"), 58 | current_position="^".rjust(position), 59 | ) 60 | print(description) 61 | -------------------------------------------------------------------------------- /automata/tm/dtm.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with deterministic Turing machines.""" 2 | 3 | from typing import AbstractSet, Generator, Mapping, Optional, Tuple 4 | 5 | import automata.base.exceptions as exceptions 6 | import automata.tm.exceptions as tm_exceptions 7 | import automata.tm.tm as tm 8 | from automata.tm.configuration import TMConfiguration 9 | from automata.tm.tape import TMTape 10 | 11 | DTMStateT = tm.TMStateT 12 | DTMPathResultT = Tuple[DTMStateT, str, tm.TMDirectionT] 13 | DTMPathT = Mapping[str, DTMPathResultT] 14 | DTMTransitionsT = Mapping[DTMStateT, DTMPathT] 15 | 16 | 17 | class DTM(tm.TM): 18 | """ 19 | The `DTM` class is a subclass of `TM` and represents a deterministic Turing 20 | machine. 21 | 22 | Parameters 23 | ---------- 24 | states: AbstractSet[DTMStateT] 25 | A set of the DTM's valid states. 26 | input_symbols: AbstractSet[str] 27 | Set of the DTM's valid input symbols, each of which is a singleton 28 | string. 29 | tape_symbols: AbstractSet[str] 30 | Set of the DTM's valid tape symbols, each of which is a singleton 31 | string. 32 | transitions: Mapping[DTMStateT, Mapping[str, Tuple]] 33 | Dict consisting of the transitions for each state; each key is a 34 | state name, and each value is a dict which maps a symbol (the key) to 35 | a tuple consisting of the next state, the symbol to write on the 36 | tape, and the direction to move the tape head. 37 | initial_state: DTMStateT 38 | The name of the initial state for this DTM. 39 | blank_symbol: str 40 | A symbol from `tape_symbols` to be used as the blank symbol 41 | for this DTM. 42 | final_states: AbstractSet[DTMStateT] 43 | A set of final states for this DTM. 44 | 45 | Example 46 | ---------- 47 | from automata.tm.dtm import DTM 48 | # DTM which matches all strings beginning with '0's, and followed by 49 | # the same number of '1's 50 | dtm = DTM( 51 | states={'q0', 'q1', 'q2', 'q3', 'q4'}, 52 | input_symbols={'0', '1'}, 53 | tape_symbols={'0', '1', 'x', 'y', '.'}, 54 | transitions={ 55 | 'q0': { 56 | '0': ('q1', 'x', 'R'), 57 | 'y': ('q3', 'y', 'R') 58 | }, 59 | 'q1': { 60 | '0': ('q1', '0', 'R'), 61 | '1': ('q2', 'y', 'L'), 62 | 'y': ('q1', 'y', 'R') 63 | }, 64 | 'q2': { 65 | '0': ('q2', '0', 'L'), 66 | 'x': ('q0', 'x', 'R'), 67 | 'y': ('q2', 'y', 'L') 68 | }, 69 | 'q3': { 70 | 'y': ('q3', 'y', 'R'), 71 | '.': ('q4', '.', 'R') 72 | } 73 | }, 74 | initial_state='q0', 75 | blank_symbol='.', 76 | final_states={'q4'} 77 | ) 78 | """ 79 | 80 | __slots__ = ( 81 | "states", 82 | "input_symbols", 83 | "tape_symbols", 84 | "transitions", 85 | "initial_state", 86 | "blank_symbol", 87 | "final_states", 88 | ) 89 | 90 | def __init__( 91 | self, 92 | *, 93 | states: AbstractSet[DTMStateT], 94 | input_symbols: AbstractSet[str], 95 | tape_symbols: AbstractSet[str], 96 | transitions: DTMTransitionsT, 97 | initial_state: DTMStateT, 98 | blank_symbol: str, 99 | final_states: AbstractSet[DTMStateT], 100 | ) -> None: 101 | """Initialize a complete Turing machine.""" 102 | super().__init__( 103 | states=states, 104 | input_symbols=input_symbols, 105 | tape_symbols=tape_symbols, 106 | transitions=transitions, 107 | initial_state=initial_state, 108 | blank_symbol=blank_symbol, 109 | final_states=final_states, 110 | ) 111 | 112 | def _validate_transition_state(self, transition_state: DTMStateT) -> None: 113 | if transition_state not in self.states: 114 | raise exceptions.InvalidStateError( 115 | "transition state is not valid ({})".format(transition_state) 116 | ) 117 | 118 | def _validate_transition_symbols( 119 | self, state: DTMStateT, paths: DTMTransitionsT 120 | ) -> None: 121 | for tape_symbol in paths.keys(): 122 | if tape_symbol not in self.tape_symbols: 123 | raise exceptions.InvalidSymbolError( 124 | "transition symbol {} for state {} is not valid".format( 125 | tape_symbol, state 126 | ) 127 | ) 128 | 129 | def _validate_transition_result_direction( 130 | self, result_direction: tm.TMDirectionT 131 | ) -> None: 132 | if result_direction not in ("L", "N", "R"): 133 | raise tm_exceptions.InvalidDirectionError( 134 | "result direction is not valid ({})".format(result_direction) 135 | ) 136 | 137 | def _validate_transition_result(self, result: DTMPathResultT) -> None: 138 | result_state, result_symbol, result_direction = result 139 | if result_state not in self.states: 140 | raise exceptions.InvalidStateError( 141 | "result state is not valid ({})".format(result_state) 142 | ) 143 | if result_symbol not in self.tape_symbols: 144 | raise exceptions.InvalidSymbolError( 145 | "result symbol is not valid ({})".format(result_symbol) 146 | ) 147 | self._validate_transition_result_direction(result_direction) 148 | 149 | def _validate_transition_results(self, paths: DTMPathT) -> None: 150 | for result in paths.values(): 151 | self._validate_transition_result(result) 152 | 153 | def _validate_transitions(self) -> None: 154 | for state, paths in self.transitions.items(): 155 | self._validate_transition_state(state) 156 | self._validate_transition_symbols(state, paths) 157 | self._validate_transition_results(paths) 158 | 159 | def _validate_final_state_transitions(self) -> None: 160 | for final_state in self.final_states: 161 | if final_state in self.transitions: 162 | raise exceptions.FinalStateError( 163 | "final state {} has transitions defined".format(final_state) 164 | ) 165 | 166 | def validate(self) -> None: 167 | """ 168 | Raises an exception if this automaton is not internally consistent. 169 | 170 | Raises 171 | ------ 172 | InvalidStateError 173 | If this DTM has invalid states in the transition dictionary. 174 | InvalidSymbolError 175 | If this DTM has invalid symbols in the transition dictionary. 176 | InvalidDirectionError 177 | If this DTM has a transition with an invalid direction. 178 | FinalStateError 179 | If this DTM has a transition on any final states. 180 | """ 181 | self._read_input_symbol_subset() 182 | self._validate_blank_symbol() 183 | self._validate_transitions() 184 | self._validate_initial_state() 185 | self._validate_initial_state_transitions() 186 | self._validate_nonfinal_initial_state() 187 | self._validate_final_states() 188 | self._validate_final_state_transitions() 189 | 190 | def _get_transition( 191 | self, state: DTMStateT, tape_symbol: str 192 | ) -> Optional[DTMPathResultT]: 193 | """Get the transiton tuple for the given state and tape symbol.""" 194 | if state in self.transitions and tape_symbol in self.transitions[state]: 195 | return self.transitions[state][tape_symbol] 196 | else: 197 | return None 198 | 199 | def _has_accepted(self, configuration: TMConfiguration) -> bool: 200 | """Check whether the given config indicates accepted input.""" 201 | return configuration.state in self.final_states 202 | 203 | def _get_next_configuration(self, old_config: TMConfiguration) -> TMConfiguration: 204 | """Advance to the next configuration.""" 205 | next_transition = self._get_transition( 206 | old_config.state, old_config.tape.read_symbol() 207 | ) 208 | 209 | if next_transition is None: 210 | raise exceptions.RejectionException( 211 | "The machine entered a non-final configuration for which no " 212 | "transition is defined ({}, {})".format( 213 | old_config.state, old_config.tape.read_symbol() 214 | ) 215 | ) 216 | tape = old_config.tape 217 | (new_state, new_tape_symbol, direction) = next_transition 218 | tape = tape.write_symbol(new_tape_symbol) 219 | tape = tape.move(direction) 220 | return TMConfiguration(new_state, tape) 221 | 222 | def read_input_stepwise( 223 | self, input_str: str 224 | ) -> Generator[TMConfiguration, None, None]: 225 | """ 226 | Return a generator that yields the configuration of this DTM at each 227 | step while reading input. 228 | 229 | Parameters 230 | ---------- 231 | input_str : str 232 | The input string to read. 233 | 234 | Yields 235 | ------ 236 | Generator[TMConfiguration, None, None] 237 | A generator that yields the current configuration of 238 | the DTM after each step of reading input. 239 | """ 240 | current_configuration = TMConfiguration( 241 | self.initial_state, TMTape(input_str, blank_symbol=self.blank_symbol) 242 | ) 243 | yield current_configuration 244 | 245 | # The initial state cannot be a final state for a DTM, so the first 246 | # iteration is always guaranteed to run (as it should) 247 | while not self._has_accepted(current_configuration): 248 | current_configuration = self._get_next_configuration(current_configuration) 249 | yield current_configuration 250 | -------------------------------------------------------------------------------- /automata/tm/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exception classes specific to Turing machines.""" 2 | 3 | from automata.base.exceptions import AutomatonException 4 | 5 | 6 | class TMException(AutomatonException): 7 | """The base class for all machine-related errors.""" 8 | 9 | pass 10 | 11 | 12 | class InvalidDirectionError(TMException): 13 | """A direction is not a valid direction for this machine.""" 14 | 15 | pass 16 | 17 | 18 | class InconsistentTapesException(TMException): 19 | """The number of tapes defined for the multitape Turing machine is 20 | not consistent with the definitions of the transitions.""" 21 | 22 | pass 23 | 24 | 25 | class MalformedExtendedTapeError(TMException): 26 | """Extended tape for simulating an mntm as a ntm is not valid. 27 | Either there are 2 virtual heads for a virtual tape or the 28 | a head symbol is at the leftmost end of a virtual tape.""" 29 | 30 | pass 31 | -------------------------------------------------------------------------------- /automata/tm/ntm.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with nondeterministic Turing machines.""" 2 | 3 | from typing import AbstractSet, Generator, Mapping, Set, Tuple 4 | 5 | import automata.base.exceptions as exceptions 6 | import automata.tm.exceptions as tm_exceptions 7 | import automata.tm.tm as tm 8 | from automata.tm.configuration import TMConfiguration 9 | from automata.tm.tape import TMTape 10 | 11 | NTMStateT = tm.TMStateT 12 | NTMPathResultT = Tuple[NTMStateT, str, tm.TMDirectionT] 13 | NTMPathT = Mapping[str, AbstractSet[NTMPathResultT]] 14 | NTMTransitionsT = Mapping[NTMStateT, NTMPathT] 15 | 16 | 17 | class NTM(tm.TM): 18 | """ 19 | The `NTM` class is a subclass of `TM` and represents a 20 | nondeterministic Turing machine. 21 | 22 | Parameters 23 | ---------- 24 | states: AbstractSet[NTMStateT] 25 | A set of the NTM's valid states. 26 | input_symbols: AbstractSet[str] 27 | Set of the NTM's valid input symbols, each of which is a singleton 28 | string. 29 | tape_symbols: AbstractSet[str] 30 | Set of the NTM's valid tape symbols, each of which is a singleton 31 | string. 32 | transitions: Mapping[NTMStateT, Mapping[str, AbstractSet]] 33 | Dict consisting of the transitions for each state; each key is a 34 | state name, and each value is a dict which maps a symbol (the key) to 35 | a set of tuples consisting of the next state, the symbol to write on the 36 | tape, and the direction to move the tape head. 37 | initial_state: NTMStateT 38 | The name of the initial state for this NTM. 39 | blank_symbol: str 40 | A symbol from `tape_symbols` to be used as the blank symbol 41 | for this NTM. 42 | final_states: AbstractSet[NTMStateT] 43 | A set of final states for this NTM. 44 | 45 | Example 46 | ---------- 47 | from automata.tm.ntm import NTM 48 | # NTM which matches all strings beginning with '0's, and followed by 49 | # the same number of '1's 50 | # Note that the nondeterminism is not really used here. 51 | ntm = NTM( 52 | states={'q0', 'q1', 'q2', 'q3', 'q4'}, 53 | input_symbols={'0', '1'}, 54 | tape_symbols={'0', '1', 'x', 'y', '.'}, 55 | transitions={ 56 | 'q0': { 57 | '0': {('q1', 'x', 'R')}, 58 | 'y': {('q3', 'y', 'R')}, 59 | }, 60 | 'q1': { 61 | '0': {('q1', '0', 'R')}, 62 | '1': {('q2', 'y', 'L')}, 63 | 'y': {('q1', 'y', 'R')}, 64 | }, 65 | 'q2': { 66 | '0': {('q2', '0', 'L')}, 67 | 'x': {('q0', 'x', 'R')}, 68 | 'y': {('q2', 'y', 'L')}, 69 | }, 70 | 'q3': { 71 | 'y': {('q3', 'y', 'R')}, 72 | '.': {('q4', '.', 'R')}, 73 | } 74 | }, 75 | initial_state='q0', 76 | blank_symbol='.', 77 | final_states={'q4'} 78 | ) 79 | 80 | """ 81 | 82 | __slots__ = ( 83 | "states", 84 | "input_symbols", 85 | "tape_symbols", 86 | "transitions", 87 | "initial_state", 88 | "blank_symbol", 89 | "final_states", 90 | ) 91 | 92 | def __init__( 93 | self, 94 | *, 95 | states: AbstractSet[NTMStateT], 96 | input_symbols: AbstractSet[str], 97 | tape_symbols: AbstractSet[str], 98 | transitions: NTMTransitionsT, 99 | initial_state: NTMStateT, 100 | blank_symbol: str, 101 | final_states: AbstractSet[NTMStateT], 102 | ): 103 | """Initialize a complete Turing machine.""" 104 | super().__init__( 105 | states=states, 106 | input_symbols=input_symbols, 107 | tape_symbols=tape_symbols, 108 | transitions=transitions, 109 | initial_state=initial_state, 110 | blank_symbol=blank_symbol, 111 | final_states=final_states, 112 | ) 113 | 114 | def _validate_transition_state(self, transition_state: NTMStateT) -> None: 115 | if transition_state not in self.states: 116 | raise exceptions.InvalidStateError( 117 | "transition state is not valid ({})".format(transition_state) 118 | ) 119 | 120 | def _validate_transition_symbols( 121 | self, state: NTMStateT, paths: NTMTransitionsT 122 | ) -> None: 123 | for tape_symbol in paths.keys(): 124 | if tape_symbol not in self.tape_symbols: 125 | raise exceptions.InvalidSymbolError( 126 | "transition symbol {} for state {} is not valid".format( 127 | tape_symbol, state 128 | ) 129 | ) 130 | 131 | def _validate_transition_result_direction( 132 | self, result_direction: tm.TMDirectionT 133 | ) -> None: 134 | if result_direction not in ("L", "N", "R"): 135 | raise tm_exceptions.InvalidDirectionError( 136 | "result direction is not valid ({})".format(result_direction) 137 | ) 138 | 139 | def _validate_transition_result(self, result: NTMPathResultT) -> None: 140 | result_state, result_symbol, result_direction = result 141 | if result_state not in self.states: 142 | raise exceptions.InvalidStateError( 143 | "result state is not valid ({})".format(result_state) 144 | ) 145 | if result_symbol not in self.tape_symbols: 146 | raise exceptions.InvalidSymbolError( 147 | "result symbol is not valid ({})".format(result_symbol) 148 | ) 149 | self._validate_transition_result_direction(result_direction) 150 | 151 | def _validate_transition_results(self, paths: NTMPathT) -> None: 152 | for results in paths.values(): 153 | for result in results: 154 | self._validate_transition_result(result) 155 | 156 | def _validate_transitions(self) -> None: 157 | for state, paths in self.transitions.items(): 158 | self._validate_transition_state(state) 159 | self._validate_transition_symbols(state, paths) 160 | self._validate_transition_results(paths) 161 | 162 | def _validate_final_state_transitions(self) -> None: 163 | for final_state in self.final_states: 164 | if final_state in self.transitions: 165 | raise exceptions.FinalStateError( 166 | "final state {} has transitions defined".format(final_state) 167 | ) 168 | 169 | def validate(self) -> None: 170 | """ 171 | Raises an exception if this automaton is not internally consistent. 172 | 173 | Raises 174 | ------ 175 | InvalidStateError 176 | If this NTM has invalid states in the transition dictionary. 177 | InvalidSymbolError 178 | If this NTM has invalid symbols in the transition dictionary. 179 | InvalidDirectionError 180 | If this NTM has a transition with an invalid direction. 181 | FinalStateError 182 | If this NTM has a transition on any final states. 183 | """ 184 | 185 | self._read_input_symbol_subset() 186 | self._validate_blank_symbol() 187 | self._validate_transitions() 188 | self._validate_initial_state() 189 | self._validate_initial_state_transitions() 190 | self._validate_nonfinal_initial_state() 191 | self._validate_final_states() 192 | self._validate_final_state_transitions() 193 | 194 | def _get_transitions( 195 | self, state: NTMStateT, tape_symbol: str 196 | ) -> Set[NTMPathResultT]: 197 | """Get the transition tuples for the given state and tape symbol.""" 198 | if state in self.transitions and tape_symbol in self.transitions[state]: 199 | return self.transitions[state][tape_symbol] 200 | else: 201 | return set() 202 | 203 | def _has_accepted(self, configuration: TMConfiguration) -> bool: 204 | """Check whether the given config indicates accepted input.""" 205 | return configuration.state in self.final_states 206 | 207 | def _get_next_configurations( 208 | self, old_config: TMConfiguration 209 | ) -> Set[TMConfiguration]: 210 | """Advance to the next configurations.""" 211 | transitions = self._get_transitions( 212 | old_config.state, old_config.tape.read_symbol() 213 | ) 214 | new_configs = set() 215 | for new_state, new_tape_symbol, direction in transitions: 216 | tape = old_config.tape 217 | tape = tape.write_symbol(new_tape_symbol) 218 | tape = tape.move(direction) 219 | new_configs.add(TMConfiguration(new_state, tape)) 220 | return new_configs 221 | 222 | def read_input_stepwise( 223 | self, input_str: str 224 | ) -> Generator[Set[TMConfiguration], None, None]: 225 | """ 226 | Check if the given string is accepted by this Turing machine. 227 | 228 | Yield the current configurations of the machine at each step. 229 | 230 | Parameters 231 | ---------- 232 | input_str : str 233 | The input string to read. 234 | 235 | Yields 236 | ------ 237 | Generator[Set[TMConfiguration], None, None] 238 | A generator that yields the current configuration of 239 | the NTM after each step of reading input. 240 | 241 | Raises 242 | ------ 243 | RejectionException 244 | Raised if this NTM does not accept the input string. 245 | """ 246 | 247 | current_configurations = { 248 | TMConfiguration( 249 | self.initial_state, TMTape(input_str, blank_symbol=self.blank_symbol) 250 | ) 251 | } 252 | yield current_configurations 253 | 254 | # The initial state cannot be a final state for a NTM, so the first 255 | # iteration is always guaranteed to run (as it should) 256 | while current_configurations: 257 | new_configurations = set() 258 | for config in current_configurations: 259 | if self._has_accepted(config): 260 | # One accepting configuration is enough. 261 | return 262 | new_configurations.update(self._get_next_configurations(config)) 263 | current_configurations = new_configurations 264 | yield current_configurations 265 | 266 | raise exceptions.RejectionException( 267 | "the NTM did not reach an accepting configuration" 268 | ) 269 | -------------------------------------------------------------------------------- /automata/tm/tape.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with Turing machine tapes.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Iterator, Sequence, Tuple 5 | 6 | from typing_extensions import Self 7 | 8 | from automata.tm.tm import TMDirectionT 9 | 10 | 11 | @dataclass(frozen=True) 12 | class TMTape: 13 | """A Turing machine tape.""" 14 | 15 | __slots__ = ("tape", "blank_symbol", "current_position") 16 | 17 | tape: Tuple[str] 18 | blank_symbol: str 19 | current_position: int 20 | 21 | def __init__( 22 | self, tape: Sequence[str], blank_symbol: str, current_position: int = 0 23 | ) -> None: 24 | """Initialize a new Turing machine tape.""" 25 | tape = list(tape) 26 | # Make sure that there's something under the cursor. 27 | while len(tape) <= current_position: 28 | tape.append(blank_symbol) 29 | object.__setattr__(self, "tape", tuple(tape)) 30 | object.__setattr__(self, "blank_symbol", blank_symbol) 31 | object.__setattr__(self, "current_position", current_position) 32 | 33 | def read_symbol(self) -> str: 34 | """ 35 | Get the symbol at the current position in the tape. 36 | 37 | Returns 38 | ------ 39 | str 40 | The symbol at the current position in the tape. 41 | """ 42 | 43 | return self.tape[self.current_position] 44 | 45 | def write_symbol(self, new_tape_symbol: str) -> Self: 46 | """ 47 | Write the given symbol at the current position in the tape. 48 | 49 | Parameters 50 | ------ 51 | new_tape_symbol: str 52 | The new symbol to write onto the tape. 53 | 54 | Returns 55 | ------ 56 | TMTape 57 | A copy of the TMTape with the new symbol written at the current position. 58 | """ 59 | 60 | tape_elements = list(self.tape) 61 | tape_elements[self.current_position] = new_tape_symbol 62 | return self.__class__( 63 | tape_elements, 64 | blank_symbol=self.blank_symbol, 65 | current_position=self.current_position, 66 | ) 67 | 68 | def move(self, direction: TMDirectionT) -> Self: 69 | """ 70 | Move the tape to the next symbol in the given direction. 71 | 72 | Parameters 73 | ------ 74 | direction: TMDirectionT 75 | The direction to move the current position of the tape. 76 | 77 | Returns 78 | ------ 79 | TMTape 80 | A copy of the TMTape with the current position moved. 81 | """ 82 | 83 | # Copy stuff. 84 | new_tape = list(self.tape) 85 | new_position = self.current_position 86 | if direction == "R": 87 | new_position += 1 88 | elif direction == "N": 89 | pass 90 | elif direction == "L": 91 | new_position -= 1 92 | # Make sure that the cursor doesn't run off the end of the tape. 93 | if new_position == -1: 94 | new_tape.insert(0, self.blank_symbol) 95 | new_position += 1 96 | if new_position == len(new_tape): 97 | new_tape.append(self.blank_symbol) 98 | return self.__class__( 99 | new_tape, blank_symbol=self.blank_symbol, current_position=new_position 100 | ) 101 | 102 | def copy(self) -> Self: 103 | """ 104 | Make a copy of this TMTape. 105 | 106 | Returns 107 | ------ 108 | TMTape 109 | A copy of the TMTape. 110 | """ 111 | return self.__class__( 112 | list(self.tape).copy(), 113 | blank_symbol=self.blank_symbol, 114 | current_position=self.current_position, 115 | ) 116 | 117 | def get_symbols_as_str(self) -> str: 118 | return "".join(self.tape) 119 | 120 | def __len__(self) -> int: 121 | """Return the number of symbols on the tape.""" 122 | return len(self.tape) # TODO: do we count the blank symbols? 123 | 124 | def __iter__(self) -> Iterator[str]: 125 | """Return an iterator for the tape.""" 126 | return iter(self.tape) 127 | 128 | def __repr__(self) -> str: 129 | """Return a string representation of the tape.""" 130 | return "{}({!r}, {!r}, {!r})".format( 131 | self.__class__.__name__, 132 | "".join(self.tape), 133 | self.blank_symbol, 134 | self.current_position, 135 | ) 136 | -------------------------------------------------------------------------------- /automata/tm/tm.py: -------------------------------------------------------------------------------- 1 | """Classes and methods for working with all Turing machines.""" 2 | 3 | import abc 4 | from typing import AbstractSet, Literal 5 | 6 | import automata.base.exceptions as exceptions 7 | from automata.base.automaton import Automaton, AutomatonStateT 8 | 9 | TMStateT = AutomatonStateT 10 | TMDirectionT = Literal["L", "R", "N"] 11 | 12 | 13 | class TM(Automaton, metaclass=abc.ABCMeta): 14 | """An abstract base class for Turing machines.""" 15 | 16 | __slots__ = tuple() 17 | 18 | tape_symbols: AbstractSet[str] 19 | blank_symbol: str 20 | 21 | def _read_input_symbol_subset(self) -> None: 22 | if not (self.input_symbols < self.tape_symbols): 23 | raise exceptions.MissingSymbolError( 24 | "The set of tape symbols is missing symbols from the input " 25 | "symbol set ({})".format(self.tape_symbols - self.input_symbols) 26 | ) 27 | 28 | def _validate_blank_symbol(self) -> None: 29 | """Raise an error if blank symbol is not a tape symbol.""" 30 | if self.blank_symbol not in self.tape_symbols: 31 | raise exceptions.InvalidSymbolError( 32 | "blank symbol {} is not a tape symbol".format(self.blank_symbol) 33 | ) 34 | 35 | def _validate_nonfinal_initial_state(self) -> None: 36 | """Raise an error if the initial state is a final state.""" 37 | if self.initial_state in self.final_states: 38 | raise exceptions.InitialStateError( 39 | "initial state {} cannot be a final state".format(self.initial_state) 40 | ) 41 | -------------------------------------------------------------------------------- /automata/tm/tools.py: -------------------------------------------------------------------------------- 1 | """Functions for displaying and maniuplating Turing machines.""" 2 | 3 | from typing import Sequence 4 | 5 | from automata.tm.configuration import TMConfiguration 6 | 7 | 8 | def print_configs(validation_generator: Sequence[TMConfiguration]) -> None: 9 | """ 10 | Print each machine configuration represented by the given sequence. 11 | 12 | Parameters 13 | ---------- 14 | validation_generator : Sequence[TMConfiguration] 15 | A sequence containing the TMConfigurations to print. 16 | """ 17 | 18 | for config in validation_generator: 19 | config.print() 20 | -------------------------------------------------------------------------------- /docs/api/base-exception-classes.md: -------------------------------------------------------------------------------- 1 | # Base exception classes 2 | 3 | ::: automata.base.exceptions 4 | -------------------------------------------------------------------------------- /docs/api/class-automaton.md: -------------------------------------------------------------------------------- 1 | # class Automaton(metaclass=ABCMeta) 2 | 3 | ::: automata.base.automaton.Automaton 4 | -------------------------------------------------------------------------------- /docs/api/fa/class-dfa.md: -------------------------------------------------------------------------------- 1 | # class DFA(FA) 2 | 3 | ::: automata.fa.dfa 4 | -------------------------------------------------------------------------------- /docs/api/fa/class-fa.md: -------------------------------------------------------------------------------- 1 | # class FA(Automaton, metaclass=ABCMeta) 2 | 3 | ::: automata.fa.fa 4 | -------------------------------------------------------------------------------- /docs/api/fa/class-gnfa.md: -------------------------------------------------------------------------------- 1 | # class GNFA(FA) 2 | 3 | ::: automata.fa.gnfa 4 | -------------------------------------------------------------------------------- /docs/api/fa/class-nfa.md: -------------------------------------------------------------------------------- 1 | # class NFA(FA) 2 | 3 | ::: automata.fa.nfa 4 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | This is the API documentation generated by the class and method docstrings for the `automata` package. 4 | -------------------------------------------------------------------------------- /docs/api/pda/class-dpda.md: -------------------------------------------------------------------------------- 1 | # class DPDA(PDA) 2 | 3 | ::: automata.pda.dpda 4 | -------------------------------------------------------------------------------- /docs/api/pda/class-npda.md: -------------------------------------------------------------------------------- 1 | # class NPDA(PDA) 2 | 3 | ::: automata.pda.npda 4 | -------------------------------------------------------------------------------- /docs/api/pda/class-pda.md: -------------------------------------------------------------------------------- 1 | # class PDA(Automaton, metaclass=ABCMeta) 2 | 3 | ::: automata.pda.pda 4 | -------------------------------------------------------------------------------- /docs/api/regular-expressions.md: -------------------------------------------------------------------------------- 1 | # Regular Expressions 2 | 3 | ::: automata.regex.regex 4 | -------------------------------------------------------------------------------- /docs/api/tm/class-dtm.md: -------------------------------------------------------------------------------- 1 | # class DTM(TM) 2 | 3 | ::: automata.tm.dtm 4 | -------------------------------------------------------------------------------- /docs/api/tm/class-mntm.md: -------------------------------------------------------------------------------- 1 | # class MNTM(TM) 2 | 3 | ::: automata.tm.mntm 4 | -------------------------------------------------------------------------------- /docs/api/tm/class-ntm.md: -------------------------------------------------------------------------------- 1 | # class NTM(TM) 2 | 3 | ::: automata.tm.ntm 4 | -------------------------------------------------------------------------------- /docs/api/tm/class-tm.md: -------------------------------------------------------------------------------- 1 | # class TM(Automaton, metaclass=ABCMeta) 2 | 3 | ::: automata.tm.tm 4 | -------------------------------------------------------------------------------- /docs/api/tm/tm-exception-classes.md: -------------------------------------------------------------------------------- 1 | # Turing machine exception classes 2 | 3 | ::: automata.tm.exceptions 4 | -------------------------------------------------------------------------------- /docs/assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/docs/assets/img/favicon.png -------------------------------------------------------------------------------- /docs/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /docs/characteristics.md: -------------------------------------------------------------------------------- 1 | # Automaton Characteristics 2 | 3 | ## Automaton instances are immutable 4 | 5 | All Automaton instances are fully immutable to protect against common pitfalls, 6 | such as mutating an automaton to an invalid state after it's already been 7 | validated. 8 | 9 | This means that if you wish to make a change to an automaton instance, you must 10 | retrieve its attributes as a dictionary (using the `input_parameters` property), 11 | make your desired change, then pass those parameters to the relevant 12 | constructor. For example: 13 | 14 | ```python 15 | from automata.fa.dfa import DFA 16 | 17 | dfa1 = DFA( 18 | states={'q0', 'q1', 'q2'}, 19 | input_symbols={'0', '1'}, 20 | transitions={ 21 | 'q0': {'0': 'q0', '1': 'q1'}, 22 | 'q1': {'0': 'q0', '1': 'q2'}, 23 | 'q2': {'0': 'q2', '1': 'q1'} 24 | }, 25 | initial_state='q0', 26 | final_states={'q1'} 27 | ) 28 | # If you want to make a change, you must create a new instance; please note 29 | # that dfa1.input_parameters is always a deep copy of the input parameters for 30 | # dfa1 (in other words, mutating dfa1.input_parameters will not actually mutate 31 | # dfa1) 32 | params = dfa1.input_parameters 33 | params['final_states'] = {'q2'} 34 | dfa2 = DFA(**params) 35 | ``` 36 | 37 | ### Enabling mutable automata 38 | 39 | Automaton immutability is enforced via a "freeze" step during object 40 | initialization that turns mutable parameters (such as sets or dicts) into their 41 | immutable counterparts (frozensets/frozendicts). 42 | 43 | If your application requires maximum performance, you can disable this 44 | conversion via the `allow_mutable_automata` global configuration option. If 45 | enabled, the user must ensure that their automaton instances are never modified, 46 | otherwise correct behavior cannot be guaranteed. 47 | 48 | ```python 49 | import automata.base.config as global_config 50 | 51 | global_config.allow_mutable_automata = True 52 | # The rest of your code... 53 | ``` 54 | 55 | ## Automaton instances are validated by default 56 | 57 | By default, all Automaton instances are checked for common inconsistencies when 58 | they are instantiated. If inconsistencies are found, the appropriate exception 59 | from `automata.base.exceptions` is raised. 60 | 61 | Because this validation can be performance-intensive for large automaton 62 | instances with many states/transitions, you can disable the automatic validation 63 | using the global configuration feature (introduced in v7): 64 | 65 | ```python 66 | import automata.base.config as global_config 67 | 68 | global_config.should_validate_automata = False 69 | 70 | # The rest of your code... 71 | ``` 72 | 73 | If, at any point, you wish to opt into validation for a specific Automaton instance, you can call the `validate` method: 74 | 75 | ```python 76 | my_automaton.validate() 77 | ``` 78 | -------------------------------------------------------------------------------- /docs/examples/fa-examples.md: -------------------------------------------------------------------------------- 1 | # Finite Automata Examples 2 | 3 | On this page, we give some short examples with discussion for the finite 4 | automata (sometimes called finite state machines) classes and methods in 5 | this package. At a high level, a finite automaton (FA) is an abstract 6 | machine that can be in any one of a finite number of _states_, and 7 | moves between states based on a _transition function_ in response to 8 | reading characters from an input string. The FA will _accept_ or _reject_ an 9 | input string depending on its current state. 10 | 11 | For a detailed overview of this topic, see [this Wikipedia article][wikipedia-fsm] 12 | or [these lecture notes][lecture-notes]. 13 | 14 | ## Reading input 15 | 16 | In this example, we first define a function that takes in an automaton 17 | and asks the user for input strings, printing whether the input was 18 | accepted or rejected: 19 | 20 | ```python 21 | def read_user_input(my_automaton): 22 | try: 23 | while True: 24 | if my_automaton.accepts_input(input("Please enter your input: ")): 25 | print("Accepted") 26 | else: 27 | print("Rejected") 28 | except KeyboardInterrupt: 29 | print("") 30 | ``` 31 | 32 | ### Deterministic finite automaton (DFA) 33 | 34 | To use this function, let's first define a DFA. 35 | For a detailed definiton, see [this Wikipedia article on DFAs][wikipedia-dfa]. 36 | 37 | ```python 38 | from automata.fa.dfa import DFA 39 | 40 | # DFA which matches all binary strings ending in an odd number of '1's 41 | my_dfa = DFA( 42 | states={'q0', 'q1', 'q2'}, 43 | input_symbols={'0', '1'}, 44 | transitions={ 45 | 'q0': {'0': 'q0', '1': 'q1'}, 46 | 'q1': {'0': 'q0', '1': 'q2'}, 47 | 'q2': {'0': 'q2', '1': 'q1'} 48 | }, 49 | initial_state='q0', 50 | final_states={'q1'} 51 | ) 52 | ``` 53 | 54 | We can generate a picture of our DFA using the package: 55 | 56 | ```python 57 | my_dfa.show_diagram() 58 | ``` 59 | 60 | This produces the following: 61 | 62 | ![my dfa image](img/my_dfa.svg) 63 | 64 | Now that we've defined our DFA, we can see our funciton in action: 65 | 66 | ```python 67 | read_user_input(my_dfa) 68 | # 001 -> Accepted 69 | # 011 -> Rejected 70 | # 000111 -> Accepted 71 | ``` 72 | 73 | ### Nondeterministic finite automaton (NFA) 74 | 75 | We can also do the same with an NFA we define. Note that the 76 | transition dictionary for the NFA has a different structure than 77 | that of the DFA, and that we are working over a different input 78 | alphabet than the previous example. For a detailed definiton, see [this Wikipedia article on NFAs][wikipedia-nfa]. 79 | 80 | ```python 81 | from automata.fa.nfa import NFA 82 | 83 | # NFA which matches strings beginning with "a", ending with "a", and 84 | # containing no consecutive "b"s 85 | my_nfa = NFA( 86 | states={"q0", "q1", "q2"}, 87 | input_symbols={"a", "b"}, 88 | transitions={ 89 | "q0": {"a": {"q1"}}, 90 | "q1": {"a": {"q1"}, "": {"q2"}}, 91 | "q2": {"b": {"q0"}}, 92 | }, 93 | initial_state="q0", 94 | final_states={"q1"}, 95 | ) 96 | ``` 97 | 98 | Similar to the DFA, we can generate a picture of our NFA: 99 | 100 | ```python 101 | my_nfa.show_diagram() 102 | ``` 103 | 104 | This produces the following: 105 | 106 | ![my nfa image](img/my_nfa.svg) 107 | 108 | We can call our function as in the prior example: 109 | 110 | ```python 111 | read_user_input(my_nfa) 112 | # b -> Rejected 113 | # aa -> Accepted 114 | # abaa -> Accepted 115 | ``` 116 | 117 | ## Subset for NFAs 118 | 119 | The `NFA` does not have a built-in method for checking whether it is a subset 120 | of another `NFA`. However, this can be done using existing methods in the 121 | package: 122 | 123 | ```python 124 | import string 125 | from automata.fa.nfa import NFA 126 | 127 | def is_subset(nfa1, nfa2): 128 | # In the following, we have nfa1 and nfa2 and want to determine whether 129 | # nfa1 is a subset of nfa2. 130 | 131 | # If taking the union of nfa2 with nfa1 is equal to nfa2 again, 132 | # nfa1 didn't accept any strings that nfa2 did not, so it is a subset. 133 | return nfa1.union(nfa2) == nfa2 134 | ``` 135 | 136 | To see our function in action, we need to define some NFAs. We can 137 | do this easily by converting from regular expressions. For more information 138 | about this equivalence, see [the Wikipedia article on regular languages][wikipedia-reglang]: 139 | 140 | ```python 141 | alphabet = set(string.ascii_lowercase) 142 | 143 | nfa1 = NFA.from_regex("abc", input_symbols=alphabet) 144 | nfa2 = NFA.from_regex("(abc)|(def)", input_symbols=alphabet) 145 | nfa3 = NFA.from_regex("a*bc", input_symbols=alphabet) 146 | ``` 147 | 148 | With these NFAs, we can now call the function and check that it matches the 149 | expected results. 150 | 151 | ```python 152 | print(is_subset(nfa1, nfa2)) # True 153 | print(is_subset(nfa1, nfa3)) # True 154 | print(is_subset(nfa2, nfa3)) # False 155 | ``` 156 | 157 | ## Edit distance automaton 158 | 159 | The following example is inspired by [this blog post][levelshtein-article]. 160 | Essentially, we want to determine which strings in a given set are within 161 | the target edit distance to a reference string. We do this by creating an 162 | edit distance NFA and intersecting it with a DFA recognizing our original 163 | set of strings: 164 | 165 | ```python 166 | import string 167 | 168 | from automata.fa.dfa import DFA 169 | from automata.fa.nfa import NFA 170 | 171 | 172 | def words_within_edit_distance(edit_distance, reference_string, target_words): 173 | input_symbols = set(string.ascii_lowercase) 174 | 175 | # Construct DFA recognizing target words 176 | target_words_dfa = DFA.from_finite_language( 177 | input_symbols, 178 | target_words, 179 | ) 180 | 181 | # Next, construct NFA recognizing all strings 182 | # within given edit distance of target word 183 | words_within_edit_distance_dfa = DFA.from_nfa( 184 | NFA.edit_distance( 185 | input_symbols, 186 | reference_string, 187 | edit_distance, 188 | ) 189 | ) 190 | 191 | # Take intersection and return results 192 | found_words_dfa = target_words_dfa & words_within_edit_distance_dfa 193 | return set(found_words_dfa) 194 | 195 | 196 | target_words = {"these", "are", "target", "words", "them", "those"} 197 | reference_string = "they" 198 | edit_distance = 2 199 | 200 | found_words = words_within_edit_distance(edit_distance, reference_string, target_words) 201 | 202 | # Set is {"these", "them"} 203 | print( 204 | f"All words within edit distance {edit_distance} of " 205 | f"'{reference_string}': {found_words}" 206 | ) 207 | ``` 208 | 209 | ## Making a transition table 210 | 211 | The example below is adapted from the 212 | [visual automata](https://github.com/lewiuberg/visual-automata) library. 213 | This function takes in a DFA or NFA and returns the 214 | corresponding transition table. 215 | 216 | The start state is prefixed with `→` and final states are prefixed 217 | with `*`. 218 | 219 | ```python 220 | import pandas as pd 221 | 222 | def make_table(target_fa) -> pd.DataFrame: 223 | initial_state = target_fa.initial_state 224 | final_states = target_fa.final_states 225 | 226 | table = {} 227 | 228 | for from_state, to_state, symbol in target_fa.iter_transitions(): 229 | # Prepare nice string for from_state 230 | if isinstance(from_state, frozenset): 231 | from_state_str = str(set(from_state)) 232 | else: 233 | from_state_str = str(from_state) 234 | 235 | if from_state in final_states: 236 | from_state_str = "*" + from_state_str 237 | if from_state == initial_state: 238 | from_state_str = "→" + from_state_str 239 | 240 | # Prepare nice string for to_state 241 | if isinstance(to_state, frozenset): 242 | to_state_str = str(set(to_state)) 243 | else: 244 | to_state_str = str(to_state) 245 | 246 | if to_state in final_states: 247 | to_state_str = "*" + to_state_str 248 | 249 | # Prepare nice symbol 250 | if symbol == "": 251 | symbol = "λ" 252 | 253 | from_state_dict = table.setdefault(from_state_str, dict()) 254 | from_state_dict.setdefault(symbol, set()).add(to_state_str) 255 | 256 | # Reformat table for singleton sets 257 | for symbol_dict in table.values(): 258 | for symbol in symbol_dict: 259 | if len(symbol_dict[symbol]) == 1: 260 | symbol_dict[symbol] = symbol_dict[symbol].pop() 261 | 262 | 263 | df = pd.DataFrame.from_dict(table).fillna("∅").T 264 | return df.reindex(sorted(df.columns), axis=1) 265 | ``` 266 | 267 | [wikipedia-fsm]: https://en.wikipedia.org/wiki/Finite-state_machine 268 | [wikipedia-dfa]: https://en.wikipedia.org/wiki/Deterministic_finite_automaton 269 | [wikipedia-nfa]: https://en.wikipedia.org/wiki/Nondeterministic_finite_automaton 270 | [wikipedia-reglang]: https://en.wikipedia.org/wiki/Regular_language 271 | [lecture-notes]: https://jeffe.cs.illinois.edu/teaching/algorithms/#models 272 | [levelshtein-article]: http://blog.notdot.net/2010/07/Damn-Cool-Algorithms-Levenshtein-Automata 273 | -------------------------------------------------------------------------------- /docs/examples/img/my_dfa.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 50d723a2-e6bd-4911-8f58-a3911e899217 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | q0 22 | 23 | q0 24 | 25 | 26 | 27 | 50d723a2-e6bd-4911-8f58-a3911e899217->q0 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | q0->q0 37 | 38 | 39 | 0 40 | 41 | 42 | 43 | q1 44 | 45 | 46 | q1 47 | 48 | 49 | 50 | q0->q1 51 | 52 | 53 | 1 54 | 55 | 56 | 57 | q1->q0 58 | 59 | 60 | 0 61 | 62 | 63 | 64 | q2 65 | 66 | q2 67 | 68 | 69 | 70 | q1->q2 71 | 72 | 73 | 1 74 | 75 | 76 | 77 | q2->q1 78 | 79 | 80 | 1 81 | 82 | 83 | 84 | q2->q2 85 | 86 | 87 | 0 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /docs/examples/img/my_nfa.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 4e85672c-fa56-4acd-8f9d-c796d7b8358c 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | q0 22 | 23 | q0 24 | 25 | 26 | 27 | 4e85672c-fa56-4acd-8f9d-c796d7b8358c->q0 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | q1 37 | 38 | 39 | q1 40 | 41 | 42 | 43 | q0->q1 44 | 45 | 46 | a 47 | 48 | 49 | 50 | q1->q1 51 | 52 | 53 | a 54 | 55 | 56 | 57 | q2 58 | 59 | q2 60 | 61 | 62 | 63 | q1->q2 64 | 65 | 66 | ε 67 | 68 | 69 | 70 | q2->q0 71 | 72 | 73 | b 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /docs/examples/perf-examples.md: -------------------------------------------------------------------------------- 1 | # Performance Examples 2 | 3 | On this page, we give examples of reading large inputs with the library. 4 | We include timing code in these examples to allow for comparisons with 5 | other implementations. 6 | 7 | ## Edit distance automaton for large dictionaries 8 | 9 | This is an extended version of the [edit distance example](../fa-examples#edit-distance-automaton) on a large input. Here, we will 10 | return all words in the given English dictionary within the specified 11 | edit distance to the target word. 12 | 13 | ```python 14 | # Imports from automata library 15 | import string 16 | import time 17 | 18 | import pooch 19 | 20 | from automata.fa.dfa import DFA 21 | from automata.fa.nfa import NFA 22 | 23 | # First, get a set of all the words we'd like to use 24 | word_file = pooch.retrieve( 25 | url="https://raw.githubusercontent.com/solardiz/wordlists/master/gutenberg-all-lowercase-words.txt", 26 | known_hash="62be81d8a5cb2dae11b96bdf85568436b137b9c0961340569ca1fca595774788", 27 | ) 28 | 29 | with open(word_file, "r") as wf: 30 | word_set = set(wf.read().splitlines()) 31 | 32 | 33 | print(f"Word set size: {len(word_set):,}") 34 | 35 | # Create the DFA recognizing all the words we'd like 36 | # NOTE this DFA is minimal by construction 37 | start = time.perf_counter() 38 | input_symbols = set(string.ascii_lowercase) 39 | word_dfa = DFA.from_finite_language(input_symbols, word_set) 40 | end = time.perf_counter() 41 | 42 | print(f"Created recognizing DFA in {end-start:4f} seconds.") 43 | print(f"States in DFA: {len(word_dfa.states):,}") 44 | 45 | # Create the automaton recognizing words close to our target 46 | # word from an NFA 47 | target_word = "those" 48 | edit_distance = 2 49 | 50 | edit_distance_dfa = DFA.from_nfa( 51 | NFA.edit_distance( 52 | input_symbols, 53 | target_word, 54 | edit_distance, 55 | ) 56 | ) 57 | 58 | # Finally, take intersection and print results 59 | start = time.perf_counter() 60 | found_words_dfa = word_dfa & edit_distance_dfa 61 | found_words = list(found_words_dfa) 62 | end = time.perf_counter() 63 | 64 | print(f"DFA intersection done in {end-start:4f} seconds.") 65 | print( 66 | f"All words within edit distance {edit_distance} of " 67 | f'"{target_word}": {found_words}' 68 | ) 69 | ``` 70 | 71 | Note that in this example, the DFA construction is taking place 72 | over a relatively large alphabet (26) and with a large number 73 | of words (> 150,000). 74 | 75 | ## Minimal DFA from large randomized regex 76 | 77 | In this example, we minimize the DFA from a large, randomly 78 | generated regular expression. 79 | 80 | 81 | ```python 82 | # Do imports 83 | import random 84 | import time 85 | 86 | from automata.fa.dfa import DFA 87 | from automata.fa.nfa import NFA 88 | 89 | # Define regex parameters 90 | num_clauses = 1_000 91 | clause_size = 50 92 | 93 | # Define the input symbols and randomly 94 | # construct the regex 95 | input_symbols = ["0", "1"] 96 | raw_regex = "|".join( 97 | "".join(random.choices(input_symbols, k=clause_size)) for _ in range(num_clauses) 98 | ) 99 | 100 | # Convert the regex to NFA 101 | start = time.perf_counter() 102 | regex_nfa = NFA.from_regex(raw_regex) 103 | end = time.perf_counter() 104 | print( 105 | f"Created equivalent NFA with {len(regex_nfa.states):,} states in {end-start:4f} seconds." 106 | ) 107 | 108 | # Convert to (minimized) DFA 109 | start = time.perf_counter() 110 | regex_dfa = DFA.from_nfa(regex_nfa, minify=True) 111 | end = time.perf_counter() 112 | 113 | # Print timing and other information 114 | print( 115 | f"Created equivalent minimized DFA with {len(regex_dfa.states):,} states in {end-start:4f} seconds." 116 | ) 117 | print(f"Number of words accepted by DFA: {len(regex_dfa):,}.") 118 | print(f"Minimum word length accepted by DFA: {regex_dfa.minimum_word_length():,}.") 119 | print(f"Maximum word length accepted by DFA: {regex_dfa.minimum_word_length():,}.") 120 | ``` 121 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Automata 2 | 3 | *Copyright 2016-2025 Caleb Evans* 4 | *Released under the MIT license* 5 | 6 | [![tests](https://github.com/caleb531/automata/actions/workflows/tests.yml/badge.svg)](https://github.com/caleb531/automata/actions/workflows/tests.yml) 7 | [![Coverage Status](https://coveralls.io/repos/caleb531/automata/badge.svg?branch=main)](https://coveralls.io/r/caleb531/automata?branch=main) 8 | [![status](https://joss.theoj.org/papers/fe4d8521383598038e38bc0c948718af/status.svg)](https://joss.theoj.org/papers/fe4d8521383598038e38bc0c948718af) 9 | [![pyOpenSci](https://tinyurl.com/y22nb8up)](https://github.com/pyOpenSci/software-submission/issues/152) 10 | 11 | 12 | Automata is a Python 3 library implementing structures and algorithms for manipulating finite automata, 13 | pushdown automata, and Turing machines. The algorithms have been optimized and are capable of 14 | processing large inputs. Visualization logic has also been implemented. This package is suitable for 15 | both researchers wishing to manipulate automata and for instructors teaching courses on theoretical 16 | computer science. See [example jupyter notebooks.](https://github.com/caleb531/automata/tree/main/example_notebooks) 17 | 18 | For an overview on automata theory, see [this Wikipedia article][wikipedia-article], and 19 | for a more comprehensive introduction to each of these topics, see [these lecture notes][lecture-notes]. 20 | 21 | [wikipedia-article]: https://en.wikipedia.org/wiki/Automata_theory 22 | [lecture-notes]: https://jeffe.cs.illinois.edu/teaching/algorithms/#models 23 | 24 | The library requires Python 3.9 or newer. 25 | 26 | ## Installing 27 | 28 | You can install the latest version of Automata via pip: 29 | 30 | ```sh 31 | pip install automata-lib 32 | ``` 33 | 34 | To install the optional visual dependencies, use the `visual` extra: 35 | 36 | ```sh 37 | pip install 'automata-lib[visual]' 38 | ``` 39 | 40 | If you encounter errors building `pygraphviz`, you may need to install `graphviz`. 41 | See the instructions [here](https://graphviz.org/download/). 42 | -------------------------------------------------------------------------------- /docs/migration.md: -------------------------------------------------------------------------------- 1 | # Automata Migration Guide 2 | 3 | ## Backwards-incompatible changes from v8 to v9 4 | 5 | ### Dependency Changes 6 | 7 | Python 3.8 support has been dropped. Please upgrade to Python 3.9 or later to 8 | use Automata v9. 9 | 10 | ## Backwards-incompatible changes from v7 to v8 11 | 12 | ### Dependency Changes 13 | 14 | Python 3.7 support has been dropped. Please upgrade to Python 3.8 or later to 15 | use Automata v8. 16 | 17 | Diagrams are no longer being generated using `pydot`; this dependency has been 18 | dropped in favor of using the `visual` optional dependency, which will install 19 | `pygraphviz` and `coloraide` used for generating figures. You should install 20 | this optional dependency if you wish to generate figures. This change was to 21 | allow for native support for displaying finite automaton in Jupyter notebooks. 22 | The style of the diagrams has been lifted from the [visual automata] package, 23 | so you should take a look at the diagrams generated and see if they are still 24 | satisfactory. 25 | 26 | [visual automata]: https://pypi.org/project/visual-automata/ 27 | 28 | Other new dependencies have been added, but these will be installed automatically 29 | along with v8 of the package. 30 | 31 | ### Greater Support for Partial DFAs 32 | 33 | There is now greater support for partial DFAs, which included changing the 34 | `DFA.from_nfa()` function to return a partial DFA instead of a complete one. 35 | To obtain a complete DFA, you must now call `DFA.from_nfa().to_complete(trap_state_name)`, 36 | where `trap_state_name` will be used as the name for a trap state if one needs to 37 | be added. 38 | 39 | ### Type Hints 40 | 41 | Type hints have now been added, meaning that code which previously called functions 42 | with incorrect types may not have been flagged. See output from your typechecker 43 | for more information. 44 | 45 | ### NFA.from_regex default input symbols 46 | The default set of input symbols for `NFA.from_regex` was changed to all ascii letters and digits. 47 | If needing to use a specific set of input symbols, use the `input_symbols` parameter. 48 | 49 | ## Backwards-incompatible changes from v6 to v7 50 | 51 | ### Immutable instances 52 | 53 | All Automaton instances are now fully immutable to protect against common 54 | pitfalls, such as mutating an automaton to an invalid state after it's already 55 | been validated. 56 | 57 | This means that if you wish to make a change to an automaton instance, you must 58 | retrieve its attributes as a dictionary (using the new `input_parameters` 59 | property), make your desired change, then pass those parameters to the relevant 60 | constructor. For example: 61 | 62 | ```python 63 | from automata.fa.dfa import DFA 64 | 65 | dfa1 = DFA( 66 | states={'q0', 'q1', 'q2'}, 67 | input_symbols={'0', '1'}, 68 | transitions={ 69 | 'q0': {'0': 'q0', '1': 'q1'}, 70 | 'q1': {'0': 'q0', '1': 'q2'}, 71 | 'q2': {'0': 'q2', '1': 'q1'} 72 | }, 73 | initial_state='q0', 74 | final_states={'q1'} 75 | ) 76 | # You can still copy an automaton just fine 77 | dfa2 = dfa.copy() 78 | # If you want to make a change, you must create a new instance; please note 79 | # that dfa2.input_parameters is always a deep copy of the input parameters for 80 | # dfa2 (in other words, mutating dfa2.input_parameters will not actually mutate 81 | # dfa2) 82 | params = dfa2.input_parameters 83 | params['final_states'] = {'q2'} 84 | dfa3 = DFA(**params) 85 | ``` 86 | 87 | ### Renamed Regex Module 88 | 89 | The `automata.base.regex` module has been renamed to `automata.regex.regex` 90 | alongside the other regular expression-related modules. 91 | 92 | ### DFA.minify() defaults 93 | 94 | The default value of the `retain_names` parameter for `DFA.minify()` has been 95 | corrected from `True` to `False`; the API documentation has always stated that 96 | the default value _should_ be `False`, however the default value in the code was 97 | actually `True`; therefore, the code has been updated to match the documentation 98 | (#59) 99 | - Since this code correction may break existing developer code, this is labeled 100 | as a backwards-_incompatible_ change rather than just a mere bugfix 101 | 102 | ## Backwards-incompatible changes from v5 to v6 103 | 104 | Python 3.6 support has been dropped, since it has been end-of-life since 105 | December 2021. Please upgrade to Python 3.7 or later to use Automata v6. 106 | 107 | The [networkx][networkx] package has been added as a required dependency, providing 108 | substantial performance improvements for certain DFA/NFA methods, and also 109 | streamlining the code to improve maintainability. 110 | 111 | [networkx]: https://pypi.org/project/networkx/ 112 | 113 | ## Backwards-incompatible changes from v4 to v5 114 | 115 | Python 3.5 support has been dropped, since it has been end-of-life since 116 | September 2020. Please upgrade to Python 3.6 or later to use Automata v5. 117 | 118 | To support the new graph visualization capabilities, `pydot` has been added as a 119 | project dependency. The `pydot` package will be installed when you run `pip install automata-lib`. 120 | 121 | ## Backwards-incompatible changes from v3 to v4 122 | 123 | The only backwards-incompatible change from v3 to v4 is that support for Python 124 | 3.4 has been dropped. This is because Python 3.4 has reached end-of-life, and 125 | will no longer receive updates. For more information, please refer to the 126 | [Python 3.4.10 release notes][release-notes]. 127 | 128 | There have been no API changes from v3 to v4. 129 | 130 | [release-notes]: https://www.python.org/downloads/release/python-3410/ 131 | 132 | ## Backwards-incompatible changes from v2 to v3 133 | 134 | There have been a number of backwards-incompatible changes from Automata v2 to 135 | v3 to support the new features, including: 136 | 137 | ### Some types made immutable 138 | 139 | The `PDAStack` type is now immutable and hashable; it still represents the 140 | current stack of a PDA. 141 | 142 | Likewise, the `TMTape` is now immutable and hashable; it still represents the 143 | tape of a TM and the current cursor position. 144 | 145 | ### copy() methods removed for (now) immutable types 146 | 147 | The `copy` methods on `TMTape` and `PDAStack` have been removed, since they are 148 | now immutable types. This change is similar to how `list` has a `copy()` method 149 | but `tuple` does not. 150 | 151 | ### Acceptance mode of PDAs is now configurable 152 | 153 | `DPDA` and `NPDA` have a new config option which specifies when to accept. This 154 | can be either `'empty_stack'`, `'final_state'` or `'both'`. The default is 155 | `'both'`. 156 | 157 | ## Backwards-incompatible changes from v1 to v2 158 | 159 | There have been a number of backwards-incompatible changes from Automata v1 to 160 | v2 to clean up the API, including: 161 | 162 | ### Renames 163 | 164 | The following methods and classes have been renamed for better clarity: 165 | 166 | #### Shared module 167 | 168 | The `automata.shared` package has been renamed to `automata.base`. 169 | 170 | **Before:** 171 | ```python 172 | from automata.shared.automaton import Automaton 173 | from automata.shared.exceptions import FinalStateError 174 | ``` 175 | 176 | **After:** 177 | ```python 178 | from automata.base.automaton import Automaton 179 | from automata.base.exceptions import FinalStateError 180 | ``` 181 | 182 | #### Input validation methods 183 | 184 | The `validate_input()` method has been renamed to `read_input()`. The 185 | `validate_input(step=True)` form has also been converted to the standalone 186 | method `read_input_stepwise()`. 187 | 188 | **Before:** 189 | ```python 190 | final_state = dfa.validate_input('0011') 191 | steps = dfa.validate_input('0011', step=True) 192 | ``` 193 | 194 | **After:** 195 | ```python 196 | final_state = dfa.read_input('0011') 197 | steps = dfa.read_input_stepwise('0011') 198 | ``` 199 | 200 | #### Automaton validation methods 201 | 202 | The `validate_self()` method has been renamed to `validate()`. 203 | 204 | **Before:** 205 | ```python 206 | dfa.validate_self() 207 | ``` 208 | 209 | **After:** 210 | ```python 211 | dfa.validate() 212 | ``` 213 | 214 | #### Exceptions 215 | 216 | The top-level `*Error` exception classes has been renamed to `*Exception`. 217 | 218 | **Before:** 219 | ```python 220 | from automata.shared.exceptions import AutomatonError 221 | from automata.shared.exceptions import RejectionError 222 | from automata.pda.exceptions import PDAError 223 | from automata.tm.exceptions import TMError 224 | ``` 225 | 226 | **After:** 227 | ```python 228 | from automata.base.exceptions import AutomatonException 229 | from automata.base.exceptions import RejectionException 230 | from automata.pda.exceptions import PDAException 231 | from automata.tm.exceptions import TMException 232 | ``` 233 | 234 | ### Constructor polymorphism removed 235 | 236 | In v1, you could copy an automaton (or convert it to another type) by passing it 237 | into the constructor for an Automaton subtype. 238 | 239 | #### Copying an automaton 240 | 241 | **Before:** 242 | ```python 243 | dfa = DFA(dfa) 244 | ``` 245 | 246 | **After:** 247 | ```python 248 | dfa = dfa.copy() 249 | ``` 250 | 251 | #### Converting NFA to DFA 252 | 253 | **Before:** 254 | ```python 255 | dfa = DFA(nfa) 256 | ``` 257 | 258 | **After:** 259 | ```python 260 | dfa = DFA.from_nfa(nfa) 261 | ``` 262 | 263 | #### Converting DFA to NFA 264 | 265 | **Before:** 266 | ```python 267 | nfa = NFA(dfa) 268 | ``` 269 | 270 | **After:** 271 | ```python 272 | nfa = NFA.from_dfa(dfa) 273 | ``` 274 | -------------------------------------------------------------------------------- /docs/people.md: -------------------------------------------------------------------------------- 1 | ## Maintainers 2 | 3 | - [caleb531](https://github.com/caleb531) 4 | - [eliotwrobson](https://github.com/eliotwrobson) 5 | 6 | ## Contributors 7 | 8 | - [lewiuberg](https://github.com/lewiuberg) 9 | - [YtvwlD](https://github.com/YtvwlD) 10 | - [dengl11](https://github.com/dengl11) 11 | - [Tagl](https://github.com/Tagl) 12 | - [CamiloMartinezM](https://github.com/CamiloMartinezM) 13 | - [abhinavsinha-adrino](https://github.com/abhinavsinha-adrino) 14 | - [skyien-z](https://github.com/skyien-z) 15 | -------------------------------------------------------------------------------- /joss/finite_language_dfa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/joss/finite_language_dfa.png -------------------------------------------------------------------------------- /joss/paper.bib: -------------------------------------------------------------------------------- 1 | @InProceedings{Sutner03, 2 | author={Sutner, Klaus}, 3 | editor={Champarnaud, Jean-Marc and Maurel, Denis}, 4 | title={automata, a Hybrid System for Computational Automata Theory}, 5 | booktitle={Implementation and Application of Automata}, 6 | year={2003}, 7 | publisher={Springer Berlin Heidelberg}, 8 | address={Berlin, Heidelberg}, 9 | pages={221--227}, 10 | abstract={We present a system that performs computations on finite state machines, syntactic semigroups, and one-dimensional cellular automata.}, 11 | isbn={978-3-540-44977-5}, 12 | url={https://doi.org/10.1007/3-540-44977-9_21}, 13 | doi={10.1007/3-540-44977-9_21}, 14 | } 15 | 16 | @Misc{brics, 17 | author = {Anders M\o{}ller}, 18 | title = {dk.brics.automaton -- Finite-State Automata 19 | and Regular Expressions for {Java}}, 20 | url = {https://www.brics.dk/automaton/}, 21 | year = 2021 22 | } 23 | 24 | @article{AlmeidaMR10, 25 | author = {Marco Almeida and 26 | Nelma Moreira and 27 | Rog{\'{e}}rio Reis}, 28 | title = {Testing the Equivalence of Regular Languages}, 29 | journal = {Journal of Automata, Languages and Combinatorics}, 30 | volume = {15}, 31 | number = {1/2}, 32 | pages = {7--25}, 33 | year = {2010}, 34 | url = {https://doi.org/10.25596/jalc-2010-007}, 35 | doi = {10.25596/jalc-2010-007}, 36 | timestamp = {Mon, 11 May 2020 22:57:06 +0200}, 37 | biburl = {https://dblp.org/rec/journals/jalc/AlmeidaMR10.bib}, 38 | bibsource = {dblp computer science bibliography, https://dblp.org} 39 | } 40 | 41 | @inbook{mihov_schulz_2019, 42 | place={Cambridge}, 43 | series={Cambridge Tracts in Theoretical Computer Science}, 44 | title={The Minimal Deterministic Finite-State Automaton for a Finite Language}, 45 | DOI={10.1017/9781108756945.011}, 46 | booktitle={Finite-State Techniques: Automata, Transducers and Bimachines}, 47 | publisher={Cambridge University Press}, 48 | author={Mihov, Stoyan and Schulz, Klaus U.}, 49 | year={2019}, 50 | pages={253–278}, 51 | collection={Cambridge Tracts in Theoretical Computer Science} 52 | } 53 | 54 | @book{AhoSU86, 55 | author = {Aho, Alfred V. and Lam, Monica S. and Sethi, Ravi and Ullman, Jeffrey D.}, 56 | title = {Compilers: Principles, Techniques, and Tools (2nd Edition)}, 57 | year = {2006}, 58 | isbn = {0321486811}, 59 | publisher = {Addison-Wesley Longman Publishing Co., Inc.}, 60 | address = {USA}, 61 | pages = {152-155} 62 | } 63 | 64 | @incollection{Hopcroft71, 65 | title = {An n log n Algorithm for Minimizing States in a Finite Automaton}, 66 | editor = {Zvi Kohavi and Azaria Paz}, 67 | booktitle = {Theory of Machines and Computations}, 68 | publisher = {Academic Press}, 69 | pages = {189-196}, 70 | year = {1971}, 71 | isbn = {978-0-12-417750-5}, 72 | doi = {10.1016/B978-0-12-417750-5.50022-1}, 73 | url = {https://www.sciencedirect.com/science/article/pii/B9780124177505500221}, 74 | author = {John Hopcroft}, 75 | } 76 | 77 | 78 | @INPROCEEDINGS{Erickson23, 79 | author = {Jeff Erickson, Jason Xia, Eliot Wong Robson, Tue Do, Aidan Tzur Glickman, Zhuofan Jia, Eric Jin, Jiwon Lee, Patrick Lin, Steven Pan, Samuel Ruggerio, Tomoko Sakurayama, Andrew Yin, Yael Gertner, and Brad Solomon}, 80 | title = {Auto-graded Scaffolding Exercises For Theoretical Computer Science}, 81 | booktitle = {2023 ASEE Annual Conference \& Exposition}, 82 | year = {2023}, 83 | month = {June}, 84 | address = {Baltimore , Maryland}, 85 | publisher = {ASEE Conferences}, 86 | url = {https://peer.asee.org/42347} 87 | } 88 | 89 | @misc{Johnson_2010, 90 | title={Damn Cool Algorithms: Levenshtein Automata}, 91 | journal={Nick’s Blog}, 92 | url={http://blog.notdot.net/2010/07/Damn-Cool-Algorithms-Levenshtein-Automata}, 93 | author={Johnson, Nick}, 94 | year={2010}, 95 | month={Jul} 96 | } 97 | 98 | @article{Knuth77, 99 | author = {Knuth, Donald E. and Morris, Jr., James H. and Pratt, Vaughan R.}, 100 | title = {Fast Pattern Matching in Strings}, 101 | journal = {SIAM Journal on Computing}, 102 | volume = {6}, 103 | number = {2}, 104 | pages = {323-350}, 105 | year = {1977}, 106 | doi = {10.1137/0206024}, 107 | URL = {https://doi.org/10.1137/0206024}, 108 | eprint = {https://doi.org/10.1137/0206024} 109 | } 110 | 111 | @book{Hopcroft06, 112 | author = {Hopcroft, John E. and Motwani, Rajeev and Ullman, Jeffrey D.}, 113 | title = {Introduction to Automata Theory, Languages, and Computation (3rd Edition)}, 114 | year = {2006}, 115 | isbn = {0321455363}, 116 | publisher = {Addison-Wesley Longman Publishing Co., Inc.}, 117 | address = {USA} 118 | } 119 | 120 | @book{Sipser12, 121 | series = {Introduction to the {Theory} of {Computation}}, 122 | title = {Introduction to the Theory of Computation}, 123 | isbn = {978-1-133-18781-3}, 124 | publisher = {Cengage Learning}, 125 | author = {Sipser, M.}, 126 | year = {2012}, 127 | lccn = {2012938665}, 128 | pages = {45-47} 129 | } 130 | 131 | @article{Marschall11, 132 | title = {Construction of minimal deterministic finite automata from biological motifs}, 133 | volume = {412}, 134 | issn = {0304-3975}, 135 | url = {https://www.sciencedirect.com/science/article/pii/S0304397510006948}, 136 | doi = {10.1016/j.tcs.2010.12.003}, 137 | abstract = {Deterministic finite automata (DFAs) are constructed for various purposes in computational biology. Little attention, however, has been given to the efficient construction of minimal DFAs. In this article, we define simple non-deterministic finite automata (NFAs) and prove that the standard subset construction transforms NFAs of this type into minimal DFAs. Furthermore, we show how simple NFAs can be constructed from two types of pattern popular in bioinformatics, namely (sets of) generalized strings and (generalized) strings with a Hamming neighborhood.}, 138 | number = {8}, 139 | journal = {Theoretical Computer Science}, 140 | author = {Marschall, Tobias}, 141 | year = {2011}, 142 | keywords = {Consensus string, Deterministic finite automaton, Generalized string, Minimization, Motif}, 143 | pages = {922--930}, 144 | } 145 | 146 | @article{Knuutila01, 147 | title = {Re-describing an algorithm by {Hopcroft}}, 148 | volume = {250}, 149 | issn = {0304-3975}, 150 | url = {https://www.sciencedirect.com/science/article/pii/S0304397599001504}, 151 | doi = {10.1016/S0304-3975(99)00150-4}, 152 | abstract = {J. Hopcroft introduced already in 1970 an O(nlogn)-time algorithm for minimizing a finite deterministic automaton of n states. Although the existence of the algorithm is widely known, its theoretical justification, correctness and running time analysis are not. We give here a tutorial reconstruction of Hopcroft's algorithm focusing on a firm theoretical basis, clear correctness proofs and a well-founded computational analysis. Our analysis reveals that if the size of the input alphabet m is not fixed, then Hopcroft's original algorithm does not run in time O(mnlogn) as is commonly believed in the literature. The O(mnlogn) holds, however, for the variation presented later by D. Gries and for a new variant given in this article. We also propose a new efficient routine for refining the equivalence classes constructed in the algorithm and suggest a computationally sound heuristics as an enhancement.}, 153 | number = {1}, 154 | journal = {Theoretical Computer Science}, 155 | author = {Knuutila, Timo}, 156 | year = {2001}, 157 | keywords = {Algorithms, Finite automata, Minimization}, 158 | pages = {333--363}, 159 | } 160 | 161 | @ARTICLE{Xu16, 162 | author={Xu, Chengcheng and Chen, Shuhui and Su, Jinshu and Yiu, S. M. and Hui, Lucas C. K.}, 163 | journal={IEEE Communications Surveys & Tutorials}, 164 | title={A Survey on Regular Expression Matching for Deep Packet Inspection: Applications, Algorithms, and Hardware Platforms}, 165 | year={2016}, 166 | volume={18}, 167 | number={4}, 168 | pages={2991-3029}, 169 | doi={10.1109/COMST.2016.2566669} 170 | } 171 | 172 | @book{Rodger06, 173 | author = {Rodger, Susan H.}, 174 | title = {JFLAP: An Interactive Formal Languages and Automata Package}, 175 | year = {2006}, 176 | isbn = {0763738344}, 177 | publisher = {Jones and Bartlett Publishers, Inc.}, 178 | address = {USA} 179 | } 180 | -------------------------------------------------------------------------------- /joss/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'automata: A Python package for simulating and manipulating automata' 3 | tags: 4 | - Python 5 | - automata 6 | authors: 7 | - name: Caleb Evans 8 | orcid: 0009-0000-8896-6800 9 | affiliation: 1 # Affiliation is required 10 | - name: Eliot W. Robson 11 | corresponding: true 12 | orcid: 0000-0002-1476-6715 13 | affiliation: 2 14 | affiliations: 15 | - name: Independent Developer, USA 16 | index: 1 17 | - name: Department of Computer Science, University of Illinois, Urbana, IL, USA 18 | index: 2 19 | date: 16 July 2023 20 | bibliography: paper.bib 21 | --- 22 | 23 | # Summary 24 | 25 | Automata are abstract machines used to represent models of computation, and are a central object of study in theoretical computer science 26 | [@Hopcroft06]. Given an input string of characters over a fixed alphabet, these machines either accept or reject the string. A language corresponding to an automaton is 27 | the set of all strings it accepts. Three important families of automata in increasing order of generality are the following: 28 | 29 | 1. Finite-state automata 30 | 2. Pushdown automata 31 | 3. Turing machines 32 | 33 | The `automata` package facilitates working with these families by allowing simulation of reading input and higher-level manipulation 34 | of the corresponding languages using specialized algorithms. 35 | 36 | # Statement of need 37 | 38 | Automata are a core component of both computer science education and research, seeing further theoretical work 39 | and applications in a wide variety of areas such as computational biology [@Marschall11] and networking [@Xu16]. 40 | Consequently, the manipulation of automata with software packages has seen significant attention from 41 | researchers in the past. The similarly named Mathematica package `Automata` [@Sutner03] implements a number of 42 | algorithms for use with finite-state automata, including regular expression conversion and binary set operations. 43 | In Java, the Brics package [@brics] implements similar algorithms, while the JFLAP package [@Rodger06] places an emphasis 44 | on interactivity and simulation of more general families of automata. 45 | 46 | `automata` serves the demand for such a package in the Python software ecosystem, implementing algorithms and allowing for 47 | simulation of automata in a manner comparable to the packages described previously. As a popular high-level language, Python enables 48 | significant flexibility and ease of use that directly benefits many users. The package includes a comprehensive test suite, 49 | support for modern language features (including type annotations), and has a large number of different automata, 50 | meeting the demands of users across a wide variety of use cases. In particular, the target audience 51 | is both researchers that wish to manipulate automata, and for those in educational contexts to reinforce understanding about how these 52 | models of computation function. 53 | 54 | 55 | # The `automata` package 56 | 57 | The API of the package is designed to mimic the formal mathematical description of each automaton using built-in Python data structures 58 | (such as sets and dicts). This is for ease of use by those that are unfamiliar with these models of computation, while also providing performance 59 | suitable for tasks arising in research. In particular, algorithms in the package have been written for tackling 60 | performance on large inputs, incorporating optimizations such as only exploring the reachable set of states 61 | in the construction of a new finite-state automaton. The package also has native display integration with Jupyter 62 | notebooks, enabling easy visualization that allows students to interact with `automata` in an exploratory manner. 63 | 64 | Of note are some commonly used and technical algorithms implemented in the package for finite-state automata: 65 | 66 | - An optimized version of the Hopcroft-Karp algorithm to determine whether two deterministic finite automata (DFA) are equivalent [@AlmeidaMR10]. 67 | 68 | - The product construction algorithm for binary set operations (union, intersection, etc.) on the languages corresponding to two input DFAs [@Sipser12]. 69 | 70 | - Thompson's algorithm for converting regular expressions to equivalent nondeterministic finite automata (NFA) [@AhoSU86]. 71 | 72 | - Hopcroft's algorithm for DFA minimization [@Hopcroft71; @Knuutila01]. 73 | 74 | - A specialized algorithm for directly constructing a state-minimal DFA accepting a given finite language [@mihov_schulz_2019]. 75 | 76 | - A specialized algorithm for directly constructing a minimal DFA recognizing strings containing 77 | a given substring [@Knuth77]. 78 | 79 | To the authors' knowledge, this is the only Python package implementing all of the automata manipulation algorithms stated above. 80 | 81 | `automata` has already been cited in publications [@Erickson23], and has seen use in multiple large undergraduate courses in introductory 82 | theoretical computer science at the University of Illinois Urbana-Champaign (roughly 2000 students since Fall 2021). In this instance, the package is being used 83 | both as part of an autograder utility for finite-state automata created by students, and as an exploratory tool for use by students directly. 84 | 85 | # Example usage 86 | 87 | ![A visualization of `target_words_dfa`. Transitions on characters leading to immediate rejections are omitted.\label{fig:target_words_dfa}](finite_language_dfa.png){ width=100% } 88 | 89 | The following example is inspired by the use case described in @Johnson_2010. 90 | We wish to determine which strings in a given set are within the target edit distance 91 | to a reference string. We will first initialize a DFA corresponding to a fixed set of target words 92 | over the alphabet of all lowercase ascii characters. 93 | 94 | ```python 95 | from automata.fa.dfa import DFA 96 | from automata.fa.nfa import NFA 97 | import string 98 | 99 | target_words_dfa = DFA.from_finite_language( 100 | input_symbols=set(string.ascii_lowercase), 101 | language={'these', 'are', 'target', 'words', 'them', 'those'}, 102 | ) 103 | ``` 104 | A visualization of `target_words_dfa`, generated by the package in a Jupyter notebook, 105 | is depicted in \autoref{fig:target_words_dfa}. 106 | 107 | Next, we construct an NFA recognizing all strings within a target edit distance of a fixed 108 | reference string, and then immediately convert this to an equivalent DFA. The package provides 109 | builtin functions to make this construction easy, and we use the same alphabet as the DFA that was just created. 110 | 111 | ```python 112 | words_within_edit_distance_dfa = DFA.from_nfa( 113 | NFA.edit_distance( 114 | input_symbols=set(string.ascii_lowercase), 115 | reference_str='they', 116 | max_edit_distance=2, 117 | ) 118 | ) 119 | ``` 120 | 121 | Finally, we take the intersection of the two DFAs we have constructed and read all of 122 | the words in the output DFA into a list. The library makes this straightforward and idiomatic. 123 | 124 | ```python 125 | found_words_dfa = target_words_dfa & words_within_edit_distance_dfa 126 | found_words = list(found_words_dfa) 127 | ``` 128 | 129 | The DFA `found_words_dfa` accepts strings in the intersection of the languages of the 130 | DFAs given as input, and `found_words` is a list containing this language. Note the power of this 131 | technique is that the DFA `words_within_edit_distance_dfa` 132 | has an infinite language, meaning we could not do this same computation just using the builtin 133 | sets in Python directly (as they always represent a finite collection), although the 134 | syntax used by `automata` is very similar to promote intuition. 135 | 136 | # Acknowledgements 137 | 138 | Thanks (in no particular order) to GitHub users 139 | [YtvwlD](https://github.com/YtvwlD), 140 | [dengl11](https://github.com/dengl11), 141 | [Tagl](https://github.com/Tagl), 142 | [lewiuberg](https://github.com/lewiuberg), 143 | [CamiloMartinezM](https://github.com/CamiloMartinezM), 144 | [abhinavsinha‑adrino](https://github.com/abhinavsinha-adrino), 145 | [EduardoGoulart1](https://github.com/EduardoGoulart1), and 146 | [khoda81](https://github.com/khoda81) 147 | for their invaluable code contributions to this project. 148 | 149 | # References 150 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Automata 2 | site_description: Automata is a Python 3 library which implements the structures and algorithms for finite automata, pushdown automata, and Turing machines. 3 | site_url: https://automata.site.to.be.created.org 4 | repo_url: https://github.com/caleb531/automata 5 | repo_name: automata 6 | edit_uri: blob/main/docs/ 7 | theme: 8 | name: material 9 | palette: 10 | - scheme: default 11 | primary: indigo 12 | accent: deep purple 13 | toggle: 14 | icon: material/lightbulb 15 | name: Switch to dark mode 16 | - scheme: slate 17 | primary: indigo 18 | accent: deep purple 19 | toggle: 20 | icon: material/lightbulb-outline 21 | name: Switch to light mode 22 | features: 23 | - search.suggest 24 | - search.highlight 25 | - toc.integrate 26 | - navigation.top 27 | - navigation.indexes 28 | - navigation.tabs 29 | - content.code.copy 30 | # - navigation.tabs.sticky 31 | # - navigation.tracking 32 | # - navigation.expand 33 | # - navigation.sections 34 | # - header.autohide 35 | icon: 36 | repo: fontawesome/brands/github 37 | logo: assets/img/logo.svg 38 | favicon: assets/img/favicon.png 39 | language: en 40 | plugins: 41 | - search: 42 | separator: '[\s\-\.]' 43 | min_length: 2 44 | indexing: 45 | "full" 46 | # media_type: print 47 | # enabled_if_env: ENABLE_PDF_EXPORT 48 | - macros: 49 | enabled_if_env: ENABLE_PDF_EXPORT 50 | - mkdocstrings: 51 | enabled: !ENV [ENABLE_MKDOCSTRINGS, true] 52 | default_handler: python 53 | handlers: 54 | python: 55 | options: 56 | show_source: false 57 | docstring_style: "numpy" 58 | filters: 59 | - "!^_" 60 | - "!^__" 61 | markdown_extensions: 62 | - toc: 63 | permalink: true 64 | - pymdownx.highlight: 65 | anchor_linenums: true 66 | line_spans: __span 67 | pygments_lang_class: true 68 | # auto_title: true 69 | # anchor_linenums: true 70 | # linenums: true 71 | # linenums_style: pymdownx-inline 72 | - pymdownx.tasklist: 73 | clickable_checkbox: true 74 | custom_checkbox: true 75 | - pymdownx.superfences: 76 | custom_fences: 77 | - name: mermaid 78 | class: mermaid 79 | format: !!python/name:pymdownx.superfences.fence_code_format 80 | - pymdownx.inlinehilite 81 | - pymdownx.snippets 82 | - pymdownx.critic 83 | - pymdownx.caret 84 | - pymdownx.keys 85 | - pymdownx.mark 86 | - pymdownx.tilde 87 | - pymdownx.betterem 88 | - admonition 89 | - pymdownx.details 90 | - footnotes 91 | - attr_list 92 | - md_in_html 93 | - tables 94 | extra: 95 | social: 96 | - icon: fontawesome/brands/python 97 | link: https://pypi.org/project/automata-lib/ 98 | name: Package on PyPI 99 | # extra_css: 100 | # - stylesheets/extra.css 101 | # extra_javascript: 102 | # - javascripts/extra.js 103 | copyright: Copyright © 2016 - 2025 Caleb Evans 104 | nav: 105 | - Introduction: 106 | - index.md 107 | - migration.md 108 | - characteristics.md 109 | - Examples: 110 | - examples/fa-examples.md 111 | - examples/perf-examples.md 112 | - API: 113 | - api/index.md 114 | - Class Automaton: api/class-automaton.md 115 | - Finite Automaton (FA): 116 | - api/fa/class-fa.md 117 | - Deterministic (DFA): 118 | - api/fa/class-dfa.md 119 | - Non-Deterministic (NFA): 120 | - api/fa/class-nfa.md 121 | - Generalized Non-Deterministic (GNFA): 122 | - api/fa/class-gnfa.md 123 | - Pushdown Automaton (PDA): 124 | - api/pda/class-pda.md 125 | - Deterministic (DPDA): 126 | - api/pda/class-dpda.md 127 | - Non-Deterministic (NPDA): 128 | - api/pda/class-npda.md 129 | - Turing Machine (TM): 130 | - api/tm/class-tm.md 131 | - Deterministic (DTM): 132 | - api/tm/class-dtm.md 133 | - Non-Deterministic (NTM): 134 | - api/tm/class-ntm.md 135 | - Multi-Tape Non-Deterministic (MNTM): 136 | - api/tm/class-mntm.md 137 | - Regular Expressions: 138 | - api/regular-expressions.md 139 | - Base exception classes: 140 | - api/base-exception-classes.md 141 | - Turing machine exceptions: 142 | - api/tm/tm-exception-classes.md 143 | - Community: 144 | - people.md 145 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "automata-lib" 3 | version = "9.0.0" 4 | description = "A Python library for simulating finite automata, pushdown automata, and Turing machines" 5 | readme = "README.md" 6 | requires-python = ">=3.9" 7 | keywords = [ 8 | "automata", 9 | "finite", 10 | "non-deterministic", 11 | "pushdown", 12 | "turing", 13 | "machine", 14 | "state", 15 | ] 16 | license = { file = 'LICENSE.txt' } 17 | authors = [{ name = 'Caleb Evans', email = 'caleb@calebevans.me' }] 18 | maintainers = [ 19 | { name = 'Caleb Evans', email = 'caleb@calebevans.me' }, 20 | { name = 'Eliot W. Robson', email = 'eliot.robson24@gmail.com' }, 21 | ] 22 | dependencies = [ 23 | "networkx>=2.6.2", 24 | "frozendict>=2.3.4", 25 | "typing-extensions>=4.5.0", 26 | "cached_method>=0.1.0", 27 | ] 28 | 29 | classifiers = [ 30 | "Development Status :: 5 - Production/Stable", 31 | "License :: OSI Approved :: MIT License", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | "Topic :: Scientific/Engineering :: Mathematics", 38 | "Intended Audience :: Education", 39 | "Intended Audience :: Science/Research", 40 | ] 41 | 42 | # Per https://setuptools.pypa.io/en/latest/userguide/dependency_management.html#optional-dependencies 43 | [project.optional-dependencies] 44 | visual = ["coloraide>=1.8.2", "pygraphviz>=1.10"] 45 | 46 | [tool.setuptools.package-data] 47 | "automata" = ["py.typed"] 48 | 49 | [project.urls] 50 | homepage = "https://github.com/caleb531/automata" 51 | documentation = "https://caleb531.github.io/automata/" 52 | repository = "https://github.com/caleb531/automata" 53 | changelog = "https://github.com/caleb531/automata/releases" 54 | 55 | [build-system] 56 | requires = ["setuptools"] 57 | build-backend = "setuptools.build_meta" 58 | 59 | [tool.mypy] 60 | exclude = ["build"] 61 | disallow_untyped_defs = true 62 | 63 | [[tool.mypy.overrides]] 64 | module = [ 65 | "setuptools.*", 66 | "networkx.*", 67 | "pygraphviz.*", 68 | "cached_method.*", 69 | "nose2.tools.*", 70 | ] 71 | ignore_missing_imports = true 72 | 73 | [tool.ruff] 74 | include = ["**/*.py"] 75 | 76 | [tool.ruff.lint] 77 | select = [ 78 | # pycodestyle 79 | "E", 80 | "W", 81 | # Pyflakes 82 | "F", 83 | # isort 84 | "I", 85 | # Perflint 86 | "PERF", 87 | # flake8-executable 88 | "EXE", 89 | ] 90 | 91 | 92 | # Configuration for coverage.py (https://pypi.python.org/pypi/coverage) 93 | 94 | [tool.coverage.run] 95 | # Enable branch coverage 96 | branch = true 97 | 98 | [tool.coverage.report] 99 | # Regexes for lines to exclude from consideration 100 | exclude_lines = [ 101 | "pragma: no cover", 102 | # Ignore non-runnable code 103 | "if __name__ == .__main__.:", 104 | "pass", 105 | ] 106 | 107 | # Only check coverage for source files 108 | include = ["automata/*/*.py"] 109 | 110 | # Only one module in this repo 111 | [tool.setuptools.packages.find] 112 | exclude = ["joss"] 113 | -------------------------------------------------------------------------------- /requirements.docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.6.1 2 | mkdocs-material==9.5.50 3 | mkdocs-macros-plugin==1.3.7 4 | mkdocstrings==0.27.0 5 | mkdocstrings-python==1.13.0 6 | setuptools==75.8.0 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | build==1.0.3 2 | cached_method==0.1.0 3 | click==8.1.3 4 | coloraide==1.8.2 5 | coverage==7.4.1 6 | frozendict==2.3.4 7 | mypy==1.8.0 8 | mypy-extensions==1.0.0 9 | networkx==2.6.2 10 | nose2==0.14.1 11 | packaging==23.0 12 | pathspec==0.11.1 13 | platformdirs==3.2.0 14 | pygraphviz==1.10 15 | pyparsing==3.0.9 16 | pyproject_hooks==1.0.0 17 | ruff==0.9.2 18 | tomli==2.0.1 19 | types-frozendict==2.0.9 20 | typing_extensions==4.9.0 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/caleb531/automata/1bdf9b7c94f3681976b41165044c626a768fb560/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_automaton.py: -------------------------------------------------------------------------------- 1 | """Functions for testing the Automaton abstract base class.""" 2 | 3 | import unittest 4 | 5 | from automata.base.automaton import Automaton 6 | 7 | 8 | class TestAutomaton(unittest.TestCase): 9 | def test_abstract_methods_not_implemented(self) -> None: 10 | """Should raise NotImplementedError when calling abstract methods.""" 11 | abstract_methods = { 12 | "validate": (Automaton,), 13 | "read_input_stepwise": (Automaton, ""), 14 | } 15 | for method_name, method_args in abstract_methods.items(): 16 | with self.assertRaises(NotImplementedError): 17 | getattr(Automaton, method_name)(*method_args) 18 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Functions for testing the global Automata configuration.""" 2 | 3 | import unittest 4 | from unittest.mock import MagicMock, patch 5 | 6 | from frozendict import frozendict 7 | 8 | import automata.base.config as global_config 9 | from automata.fa.dfa import DFA 10 | 11 | 12 | class TestConfig(unittest.TestCase): 13 | orig_should_validate: bool 14 | orig_allow_mutable_automata: bool 15 | 16 | def setUp(self) -> None: 17 | self.orig_should_validate = global_config.should_validate_automata 18 | self.orig_allow_mutable_automata = global_config.allow_mutable_automata 19 | 20 | def tearDown(self) -> None: 21 | global_config.should_validate_automata = self.orig_should_validate 22 | global_config.allow_mutable_automata = self.orig_allow_mutable_automata 23 | 24 | @patch("automata.fa.dfa.DFA.validate") 25 | def test_disable_validation(self, validate: MagicMock) -> None: 26 | """Should disable automaton validation""" 27 | global_config.should_validate_automata = False 28 | DFA.universal_language({"0", "1"}) 29 | validate.assert_not_called() 30 | 31 | @patch("automata.base.utils.freeze_value") 32 | def test_disable_ensure_values_are_frozen(self, freeze_value: MagicMock) -> None: 33 | """Should enable automaton mutability""" 34 | global_config.allow_mutable_automata = True 35 | DFA( 36 | states=frozenset(["s1"]), 37 | input_symbols=frozenset("a"), 38 | transitions=frozendict({"s1": frozendict({"a": "s1"})}), 39 | initial_state="s1", 40 | final_states=frozenset(["s1"]), 41 | ) 42 | freeze_value.assert_not_called() 43 | 44 | # Also this should not call freeze_value nor throw any error 45 | DFA( 46 | states={"s1"}, 47 | input_symbols={"a"}, 48 | transitions={"s1": {"a": "s1"}}, 49 | initial_state="s1", 50 | final_states={"s1"}, 51 | ) 52 | freeze_value.assert_not_called() 53 | -------------------------------------------------------------------------------- /tests/test_fa.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the behavior of both DFAs and NFAs.""" 2 | 3 | import unittest 4 | 5 | from automata.fa.dfa import DFA 6 | from automata.fa.fa import FA 7 | from automata.fa.gnfa import GNFA 8 | from automata.fa.nfa import NFA 9 | 10 | 11 | class TestFA(unittest.TestCase): 12 | """A test class for testing all finite automata.""" 13 | 14 | dfa: DFA 15 | nfa: NFA 16 | gnfa: GNFA 17 | 18 | def setUp(self) -> None: 19 | """Reset test automata before every test function.""" 20 | # DFA which matches all binary strings ending in an odd number of '1's 21 | self.dfa = DFA( 22 | states={"q0", "q1", "q2"}, 23 | input_symbols={"0", "1"}, 24 | transitions={ 25 | "q0": {"0": "q0", "1": "q1"}, 26 | "q1": {"0": "q0", "1": "q2"}, 27 | "q2": {"0": "q2", "1": "q1"}, 28 | }, 29 | initial_state="q0", 30 | final_states={"q1"}, 31 | ) 32 | # NFA which matches strings beginning with 'a', ending with 'a', and 33 | # containing no consecutive 'b's 34 | self.nfa = NFA( 35 | states={"q0", "q1", "q2"}, 36 | input_symbols={"a", "b"}, 37 | transitions={ 38 | "q0": {"a": {"q1"}}, 39 | "q1": {"a": {"q1"}, "": {"q2"}}, 40 | "q2": {"b": {"q0"}}, 41 | }, 42 | initial_state="q0", 43 | final_states={"q1"}, 44 | ) 45 | # GNFA which matches strings beginning with 'a', ending with 'a', and containing 46 | # no consecutive 'b's 47 | self.gnfa = GNFA( 48 | states={"q_in", "q_f", "q0", "q1", "q2"}, 49 | input_symbols={"a", "b"}, 50 | transitions={ 51 | "q0": {"q1": "a", "q_f": None, "q2": None, "q0": None}, 52 | "q1": {"q1": "a", "q2": "", "q_f": "", "q0": None}, 53 | "q2": {"q0": "b", "q_f": None, "q2": None, "q1": None}, 54 | "q_in": {"q0": "", "q_f": None, "q2": None, "q1": None}, 55 | }, 56 | initial_state="q_in", 57 | final_state="q_f", 58 | ) 59 | 60 | 61 | class TestFAAbstract(unittest.TestCase): 62 | def test_abstract_methods_not_implemented(self) -> None: 63 | """Should raise NotImplementedError when calling abstract methods.""" 64 | 65 | abstract_methods = { 66 | "iter_transitions": (FA,), 67 | "_get_input_path": (FA, ""), 68 | } 69 | for method_name, method_args in abstract_methods.items(): 70 | with self.assertRaises(NotImplementedError): 71 | getattr(FA, method_name)(*method_args) 72 | 73 | with self.assertRaises(NotImplementedError): 74 | getattr(FA, "_get_input_path")(FA, "") 75 | 76 | def test_get_state_name(self) -> None: 77 | """Tests get_state_name function.""" 78 | 79 | self.assertEqual(FA._get_state_name(""), "λ") 80 | self.assertEqual(FA._get_state_name("abc"), "abc") 81 | self.assertEqual(FA._get_state_name(123), "123") 82 | self.assertEqual(FA._get_state_name(frozenset()), "∅") 83 | 84 | original_sets = [frozenset("abc"), frozenset("dcgf")] 85 | 86 | for original_set in original_sets: 87 | set_output = frozenset( 88 | FA._get_state_name(original_set) 89 | .replace("{", "") 90 | .replace("}", "") 91 | .replace("'", "") 92 | .replace(" ", "") 93 | .split(",") 94 | ) 95 | self.assertEqual(set_output, original_set) 96 | 97 | original_tuple = tuple("abc") 98 | 99 | tuple_output = tuple( 100 | FA._get_state_name(original_tuple) 101 | .replace("(", "") 102 | .replace(")", "") 103 | .replace("'", "") 104 | .replace(" ", "") 105 | .split(",") 106 | ) 107 | 108 | self.assertEqual(tuple_output, original_tuple) 109 | -------------------------------------------------------------------------------- /tests/test_pda.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the behavior of PDAs.""" 2 | 3 | import tempfile 4 | import unittest 5 | 6 | from automata.pda.dpda import DPDA 7 | from automata.pda.npda import NPDA 8 | from automata.pda.pda import PDA 9 | 10 | 11 | class TestPDA(unittest.TestCase): 12 | """A test class for testing all pushdown automata.""" 13 | 14 | dpda: DPDA 15 | npda: NPDA 16 | 17 | temp_dir_path = tempfile.gettempdir() 18 | 19 | def setUp(self) -> None: 20 | """Reset test automata before every test function.""" 21 | # DPDA which which matches zero or more 'a's, followed by the same 22 | # number of 'b's (accepting by final state) 23 | self.dpda = DPDA( 24 | states={"q0", "q1", "q2", "q3"}, 25 | input_symbols={"a", "b"}, 26 | stack_symbols={"0", "1"}, 27 | transitions={ 28 | "q0": {"a": {"0": ("q1", ("1", "0"))}}, 29 | "q1": {"a": {"1": ("q1", ("1", "1"))}, "b": {"1": ("q2", "")}}, 30 | "q2": {"b": {"1": ("q2", "")}, "": {"0": ("q3", ("0",))}}, 31 | }, 32 | initial_state="q0", 33 | initial_stack_symbol="0", 34 | final_states={"q3"}, 35 | acceptance_mode="final_state", 36 | ) 37 | 38 | # NPDA which matches palindromes consisting of 'a's and 'b's 39 | # (accepting by final state) 40 | # q0 reads the first half of the word, q1 the other half, q2 accepts. 41 | # But we have to guess when to switch. 42 | self.npda = NPDA( 43 | states={"q0", "q1", "q2"}, 44 | input_symbols={"a", "b"}, 45 | stack_symbols={"A", "B", "#"}, 46 | transitions={ 47 | "q0": { 48 | "": { 49 | "#": {("q2", "#")}, 50 | }, 51 | "a": { 52 | "#": {("q0", ("A", "#"))}, 53 | "A": { 54 | ("q0", ("A", "A")), 55 | ("q1", ""), 56 | }, 57 | "B": {("q0", ("A", "B"))}, 58 | }, 59 | "b": { 60 | "#": {("q0", ("B", "#"))}, 61 | "A": {("q0", ("B", "A"))}, 62 | "B": { 63 | ("q0", ("B", "B")), 64 | ("q1", ""), 65 | }, 66 | }, 67 | }, 68 | "q1": { 69 | "": {"#": {("q2", "#")}}, 70 | "a": {"A": {("q1", "")}}, 71 | "b": {"B": {("q1", "")}}, 72 | }, 73 | }, 74 | initial_state="q0", 75 | initial_stack_symbol="#", 76 | final_states={"q2"}, 77 | acceptance_mode="final_state", 78 | ) 79 | 80 | 81 | class TestPDAAbstract(unittest.TestCase): 82 | def test_abstract_methods_not_implemented(self) -> None: 83 | """Should raise NotImplementedError when calling abstract methods.""" 84 | abstract_methods = { 85 | "iter_transitions": (PDA,), 86 | "_get_input_path": (PDA, ""), 87 | } 88 | for method_name, method_args in abstract_methods.items(): 89 | with self.assertRaises(NotImplementedError): 90 | getattr(PDA, method_name)(*method_args) 91 | -------------------------------------------------------------------------------- /tests/test_pdaconfiguration.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the behavior of PDAConfigurations.""" 2 | 3 | import tests.test_pda as test_pda 4 | from automata.pda.configuration import PDAConfiguration 5 | from automata.pda.stack import PDAStack 6 | 7 | 8 | class TestPDAConfiguration(test_pda.TestPDA): 9 | """A test class for testing configurations of pushdown automata.""" 10 | 11 | def test_config_hashability(self) -> None: 12 | self.assertEqual( 13 | hash(PDAConfiguration("q0", "ab", PDAStack(["a", "b"]))), 14 | hash(PDAConfiguration("q0", "ab", PDAStack(["a", "b"]))), 15 | ) 16 | 17 | def test_config_repr(self) -> None: 18 | """Should create proper string representation of PDA configuration.""" 19 | config = PDAConfiguration("q0", "ab", PDAStack(["a", "b"])) 20 | self.assertEqual( 21 | repr(config), 22 | "PDAConfiguration('q0', 'ab', PDAStack(('a', 'b')))", # noqa 23 | ) 24 | 25 | def test_config_equality(self) -> None: 26 | """Should only be equal for equal configurations.""" 27 | config = PDAConfiguration("q0", "ab", PDAStack(["a", "b"])) 28 | 29 | self.assertEqual(config, config) 30 | self.assertEqual(config, PDAConfiguration("q0", "ab", PDAStack(["a", "b"]))) 31 | 32 | self.assertNotEqual(config, "") 33 | self.assertNotEqual(config, PDAConfiguration("q1", "ab", PDAStack(["a", "b"]))) 34 | self.assertNotEqual(config, PDAConfiguration("q0", "ab", PDAStack(["b", "b"]))) 35 | -------------------------------------------------------------------------------- /tests/test_pdastack.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the behavior of PDAStacks.""" 2 | 3 | import tests.test_pda as test_pda 4 | from automata.pda.stack import PDAStack 5 | 6 | 7 | class TestPDAStack(test_pda.TestPDA): 8 | """A test class for testing stacks of pushdown automata.""" 9 | 10 | stack: PDAStack 11 | 12 | def setUp(self) -> None: 13 | self.stack = PDAStack(["a", "b"]) 14 | 15 | def test_stack_hashability(self) -> None: 16 | self.assertEqual(hash(self.stack), hash(PDAStack(["a", "b"]))) 17 | 18 | def test_stack_iter(self) -> None: 19 | """Should loop through the PDA stack in some manner.""" 20 | self.assertEqual(list(self.stack), ["a", "b"]) 21 | 22 | def test_stack_get(self) -> None: 23 | """Should retrieve indices in the PDA stack in some manner.""" 24 | self.assertEqual(self.stack[0], "a") 25 | self.assertEqual(self.stack[1], "b") 26 | 27 | def test_stack_repr(self) -> None: 28 | """Should create proper string representation of PDA stack.""" 29 | self.assertEqual(repr(self.stack), "PDAStack(('a', 'b'))") 30 | 31 | def test_stack_equality(self) -> None: 32 | """Should only be equal for equal configurations.""" 33 | stack = PDAStack(["a", "b"]) 34 | 35 | self.assertEqual(stack, stack) 36 | self.assertEqual(stack, PDAStack(["a", "b"])) 37 | 38 | self.assertNotEqual(stack, "") 39 | self.assertNotEqual(stack, PDAStack(["a", "a"])) 40 | self.assertNotEqual(stack, PDAStack(["b", "b"])) 41 | -------------------------------------------------------------------------------- /tests/test_postfix.py: -------------------------------------------------------------------------------- 1 | """Tests of the postfix conversion and parsing utility functions.""" 2 | 3 | import unittest 4 | from typing import List, TypeVar 5 | 6 | import automata.base.exceptions as exceptions 7 | import automata.regex.postfix as postfix 8 | from automata.regex.lexer import Lexer, Token 9 | 10 | ResultT = TypeVar("ResultT") 11 | 12 | 13 | class Integer(postfix.Literal[int]): 14 | def val(self) -> int: 15 | """It evaluates to its (integer) value.""" 16 | return int(self.text) 17 | 18 | 19 | class Add(postfix.InfixOperator[int]): 20 | """Addition.""" 21 | 22 | def get_precedence(self) -> int: 23 | return 10 # Precedence: higher than integers, lower than mult 24 | 25 | def op(self, left: int, right: int) -> int: 26 | return left + right 27 | 28 | 29 | class Minus(postfix.InfixOperator[int]): 30 | """Subtraction.""" 31 | 32 | def get_precedence(self) -> int: 33 | return 10 # Precedence: higher than integers, lower than mult 34 | 35 | def op(self, left: int, right: int) -> int: 36 | return left - right 37 | 38 | 39 | class Mult(postfix.InfixOperator[int]): 40 | """Multiplication.""" 41 | 42 | def get_precedence(self) -> int: 43 | return 20 # Higher precedence than addition/substraction. 44 | 45 | def op(self, left: int, right: int) -> int: 46 | return left * right 47 | 48 | 49 | class Divide(postfix.InfixOperator[int]): 50 | """Division.""" 51 | 52 | def get_precedence(self) -> int: 53 | return 20 # Same precedence than multiplication 54 | 55 | def op(self, left: int, right: int) -> int: 56 | return left // right 57 | 58 | 59 | class TestTokens(unittest.TestCase): 60 | """Test token subclasses for not implemented errors.""" 61 | 62 | def test_token_abstract_methods_not_implemented(self) -> None: 63 | """Should raise NotImplementedError when calling abstract methods.""" 64 | 65 | with self.assertRaises(NotImplementedError): 66 | getattr(postfix.Operator, "get_precedence")(postfix.Operator) 67 | 68 | with self.assertRaises(NotImplementedError): 69 | getattr(postfix.InfixOperator, "op")(postfix.InfixOperator, None, None) 70 | 71 | with self.assertRaises(NotImplementedError): 72 | getattr(postfix.PostfixOperator, "op")(postfix.PostfixOperator, None) 73 | 74 | with self.assertRaises(NotImplementedError): 75 | getattr(postfix.Literal, "val")(postfix.Literal) 76 | 77 | 78 | class TestArithmeticParser(unittest.TestCase): 79 | """Test parsing arithmetic expressions.""" 80 | 81 | arithmetic_lexer: Lexer 82 | 83 | def test_parse_invalid_token(self) -> None: 84 | """Test exception for invalid input tokens.""" 85 | with self.assertRaises(exceptions.InvalidRegexError): 86 | postfix.parse_postfix_tokens([""]) # type: ignore 87 | 88 | def test_nested_parenthesized_expression(self) -> None: 89 | """Test parsing parenthesized expression.""" 90 | # Parsing: 91 | # "( 4 + ( 1 + 2 * 3 * ( 4 + 5 ) + 6 ) ) * 7 + 8" 92 | tokens = [ 93 | postfix.LeftParen("("), 94 | Integer("4"), 95 | Add("+"), 96 | postfix.LeftParen("("), 97 | Integer("1"), 98 | Add("+"), 99 | Integer("2"), 100 | Mult("*"), 101 | Integer("3"), 102 | Mult("*"), 103 | postfix.LeftParen("("), 104 | Integer("4"), 105 | Add("+"), 106 | Integer("5"), 107 | postfix.RightParen(")"), 108 | Add("+"), 109 | Integer("6"), 110 | postfix.RightParen(")"), 111 | postfix.RightParen(")"), 112 | Mult("*"), 113 | Integer("7"), 114 | Add("+"), 115 | Integer("8"), 116 | ] 117 | 118 | postfix_tokens = postfix.tokens_to_postfix(tokens) 119 | res = postfix.parse_postfix_tokens(postfix_tokens) 120 | self.assertEqual((4 + (1 + 2 * 3 * (4 + 5) + 6)) * 7 + 8, res) 121 | 122 | def setUp(self) -> None: 123 | self.arithmetic_lexer: Lexer = Lexer() 124 | 125 | self.arithmetic_lexer.register_token(postfix.LeftParen.from_match, r"\(") 126 | self.arithmetic_lexer.register_token(postfix.RightParen.from_match, r"\)") 127 | self.arithmetic_lexer.register_token(Integer.from_match, r"[0-9]+") 128 | self.arithmetic_lexer.register_token(Add.from_match, r"\+") 129 | self.arithmetic_lexer.register_token(Minus.from_match, r"-") 130 | self.arithmetic_lexer.register_token(Mult.from_match, r"\*") 131 | self.arithmetic_lexer.register_token(Divide.from_match, r"/") 132 | 133 | def test_expression_invalid_ordering(self) -> None: 134 | """Check for exception raised when lexing invalid regular expressions.""" 135 | 136 | with self.assertRaises(exceptions.InvalidRegexError): 137 | postfix.validate_tokens(self.arithmetic_lexer.lex("+6")) 138 | 139 | with self.assertRaises(exceptions.InvalidRegexError): 140 | postfix.validate_tokens(self.arithmetic_lexer.lex("+5+")) 141 | 142 | with self.assertRaises(exceptions.InvalidRegexError): 143 | postfix.validate_tokens(self.arithmetic_lexer.lex("6/")) 144 | 145 | with self.assertRaises(exceptions.InvalidRegexError): 146 | postfix.validate_tokens(self.arithmetic_lexer.lex("1 + 2 - + 3")) 147 | 148 | with self.assertRaises(exceptions.InvalidRegexError): 149 | postfix.validate_tokens(self.arithmetic_lexer.lex(")(")) 150 | 151 | with self.assertRaises(exceptions.InvalidRegexError): 152 | postfix.validate_tokens(self.arithmetic_lexer.lex("(((2))")) 153 | 154 | with self.assertRaises(exceptions.InvalidRegexError): 155 | postfix.validate_tokens(self.arithmetic_lexer.lex("(+5")) 156 | 157 | with self.assertRaises(exceptions.InvalidRegexError): 158 | postfix.validate_tokens(self.arithmetic_lexer.lex("6/)")) 159 | 160 | def parse(self, tokens: List[Token[ResultT]]) -> ResultT: 161 | """Helper function for parsing token list tokens""" 162 | postfix_tokens = postfix.tokens_to_postfix(tokens) 163 | return postfix.parse_postfix_tokens(postfix_tokens) 164 | 165 | def test_single_number(self) -> None: 166 | """Test parsing a single number.""" 167 | val = self.parse(self.arithmetic_lexer.lex("13")) 168 | self.assertEqual(13, val) 169 | 170 | def test_negative_number(self) -> None: 171 | """Test parsing a negative number.""" 172 | val = self.parse(self.arithmetic_lexer.lex("0-13")) 173 | self.assertEqual(-13, val) 174 | 175 | def test_simple_mult(self) -> None: 176 | """Test parsing simple multiplication.""" 177 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("2 * 4"))) 178 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("2 * 2 * 2"))) 179 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("2 * (2 * 2)"))) 180 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("(2 * 2) * 2"))) 181 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("(2 + 2) * 2"))) 182 | 183 | def test_precedence(self) -> None: 184 | """Test checking for correct precedence.""" 185 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("2 * 3 + 2"))) 186 | self.assertEqual(10, self.parse(self.arithmetic_lexer.lex("2 * (3+2)"))) 187 | 188 | def test_negative_mult(self) -> None: 189 | """Test multiplying negative numbers.""" 190 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("(0-2) * (0- 4)"))) 191 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("(0-2) * 2 * (0-2)"))) 192 | self.assertEqual(-5, self.parse(self.arithmetic_lexer.lex("1 + (0-2) * 3"))) 193 | 194 | def test_division(self) -> None: 195 | """Test dividing numbers.""" 196 | self.assertEqual(2, self.parse(self.arithmetic_lexer.lex("4 / 2"))) 197 | self.assertEqual(2, self.parse(self.arithmetic_lexer.lex("5 / 2"))) 198 | self.assertEqual(2, self.parse(self.arithmetic_lexer.lex("6 - 8 / 2"))) 199 | self.assertEqual(3, self.parse(self.arithmetic_lexer.lex("3 * 2 / 2"))) 200 | self.assertEqual(3, self.parse(self.arithmetic_lexer.lex("2 * 3 / 2"))) 201 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("8 / 2 * 2"))) 202 | self.assertEqual(8, self.parse(self.arithmetic_lexer.lex("(8 / 2) * 2"))) 203 | self.assertEqual(2, self.parse(self.arithmetic_lexer.lex("8 / (2 * 2)"))) 204 | self.assertEqual(2, self.parse(self.arithmetic_lexer.lex("16/4/2"))) 205 | -------------------------------------------------------------------------------- /tests/test_regex.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the behavior of Regex tools""" 2 | 3 | import re as regex 4 | import unittest 5 | 6 | import automata.base.exceptions as exceptions 7 | import automata.regex.regex as re 8 | from automata.fa.nfa import NFA 9 | from automata.regex.parser import StringToken, WildcardToken 10 | 11 | 12 | class TestRegex(unittest.TestCase): 13 | """A test class for testing regular expression tools""" 14 | 15 | def test_validate_valid(self) -> None: 16 | """Should pass validation for valid regular expression""" 17 | re.validate("a*") 18 | re.validate("b|a?*") 19 | 20 | def test_validate_invalid(self) -> None: 21 | """Should raise error for invalid regular expressions""" 22 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "ab|") 23 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "?") 24 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "a|b|*") 25 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "a||b") 26 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "((abc*)))((abd)") 27 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "*") 28 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "ab(bc)*((bbcd)") 29 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "a(*)") 30 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "a(|)") 31 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "a{1,0}") 32 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "a{-1,}") 33 | self.assertRaises(exceptions.InvalidRegexError, re.validate, "a{-2,-1}") 34 | 35 | def test_invalid_token_creation(self) -> None: 36 | """Should raise error for invalid class creation""" 37 | match_obj = regex.compile("a").match("a") 38 | self.assertRaises(NotImplementedError, StringToken.from_match, match_obj) 39 | self.assertRaises(NotImplementedError, WildcardToken.from_match, match_obj) 40 | 41 | def test_helper_validate_invalid(self) -> None: 42 | """Should pass validation for valid regular expression""" 43 | self.assertFalse(re._validate("a(|)")) 44 | 45 | def test_validate_unicode_characters(self) -> None: 46 | """Should pass validation for regular expressions with unicode characters.""" 47 | re.validate("(µ|🤖ù)*") 48 | 49 | def test_unicode_input_symbols(self) -> None: 50 | """Should have correct unicode input symbols.""" 51 | nfa = NFA.from_regex("(µ🔥|🔥✨?)*") 52 | self.assertEqual(nfa.input_symbols, {"µ", "🔥", "✨"}) 53 | 54 | def test_isequal(self) -> None: 55 | """Should correctly check equivalence of two regular expressions""" 56 | 57 | self.assertTrue(re.isequal("aa?", "a|aa")) 58 | self.assertTrue(re.isequal("a(a*b|b)", "aaa*b|ab")) 59 | self.assertTrue( 60 | re.isequal( 61 | "a(a*b|b)b(cd*|dc*)", 62 | "(aaa*bbcd|abbcd)d*|(aaa*bb(dcc*|(d|c))|abb(dcc*|(d|c)))", 63 | ) 64 | ) 65 | self.assertTrue( 66 | re.isequal( 67 | "(aaa*bbcd|abbcd)d*|(aaa*bb(dcc*|(d|c))|abb(dcc*|(d|c)))", 68 | "((aaaa*bbcd|aabbcd)d|abbcdd)d*|((aaaa*bb|aabb)dccc*|" 69 | "((aaaa*bbcd|aabbcd)|((aaaa*bb|aabb)(dc|(c|d))|(abbdccc*|(abb(dc|(c|d))|abbcd)))))", # noqa 70 | ) 71 | ) 72 | 73 | def test_not_isequal(self) -> None: 74 | """Should correctly check non-equivalence of two regular expressions""" 75 | 76 | self.assertFalse( 77 | re.isequal( 78 | "baaa*b(b|a)|(bab(b|a)|(bb|ba))", 79 | "baaaa*b(a|b)|(baab(a|b)|bab(bb|(a|(b|ba))))", 80 | ) 81 | ) 82 | 83 | def test_issubset(self) -> None: 84 | """Should correctly verify if re1 is subset of re2""" 85 | 86 | self.assertTrue(re.issubset("aa?", "a*")) 87 | self.assertFalse(re.issubset("a*", "a?")) 88 | self.assertTrue(re.issubset("aaa*b|bc", "a*b|b*c*")) 89 | 90 | def test_issuperset(self) -> None: 91 | """Should correctly verify if re1 is superset of re2""" 92 | 93 | self.assertFalse(re.issuperset("aa?", "a*")) 94 | self.assertTrue(re.issuperset("a*", "a?")) 95 | self.assertTrue(re.issuperset("a*b|b*c*", "aaa*b|bc")) 96 | 97 | def test_intersection(self) -> None: 98 | """Should correctly check intersection of two regular expressions""" 99 | # Basic test 100 | nfa_1 = NFA.from_regex("(0|(01))&(01)") 101 | nfa_2 = NFA.from_regex("01") 102 | 103 | self.assertEqual(nfa_1, nfa_2) 104 | 105 | # Test intersection with NFA function on unrelated regexes 106 | regex_1 = "a|abacd" 107 | regex_2 = "a(a*b|b)b(cd*|dc*)" 108 | nfa_3 = NFA.from_regex(regex_1).intersection(NFA.from_regex(regex_2)) 109 | nfa_4 = NFA.from_regex(f"({regex_1})&({regex_2})") 110 | 111 | self.assertEqual(nfa_3, nfa_4) 112 | 113 | # Test intersection subset 114 | regex_3 = "bcdaaa" 115 | nfa_5 = NFA.from_regex(regex_3) 116 | nfa_6 = NFA.from_regex(f"({regex_3}) & (bcda*)") 117 | 118 | self.assertEqual(nfa_5, nfa_6) 119 | 120 | # Test distributive law 121 | regex_4 = f"{regex_1} & (({regex_2}) | ({regex_3}))" 122 | regex_5 = f"(({regex_1}) & ({regex_2})) | (({regex_1}) & ({regex_3}))" 123 | nfa_7 = NFA.from_regex(regex_4) 124 | nfa_8 = NFA.from_regex(regex_5) 125 | 126 | self.assertEqual(nfa_7, nfa_8) 127 | 128 | def test_kleene_plus(self) -> None: 129 | """Should correctly check kleene plus of two regular expressions""" 130 | # Basic test 131 | self.assertTrue(re.isequal("aa*", "a+")) 132 | self.assertTrue(re.isequal("(abc)(abc)*", "(abc)+")) 133 | self.assertTrue(re.isequal("a&a+", "a")) 134 | 135 | self.assertFalse(re.isequal("a*", "a+")) 136 | self.assertTrue(re.issuperset("a*", "a+")) 137 | 138 | def test_wildcard(self) -> None: 139 | """Should correctly check wildcard""" 140 | 141 | input_symbols = {"a", "b", "c"} 142 | 143 | self.assertTrue(re.isequal("a|b|c", ".", input_symbols=input_symbols)) 144 | self.assertTrue( 145 | re.isequal("(abc)|(aac)|(acc)", "a.c", input_symbols=input_symbols) 146 | ) 147 | self.assertTrue(re.isequal("a&.", "a", input_symbols=input_symbols)) 148 | 149 | self.assertTrue(re.issubset("a.b", "...", input_symbols=input_symbols)) 150 | self.assertTrue(re.issuperset(".", "a|b", input_symbols=input_symbols)) 151 | 152 | def test_shuffle(self) -> None: 153 | """Should correctly check shuffle""" 154 | 155 | input_symbols = {"a", "b", "c", "d"} 156 | 157 | self.assertTrue(re.isequal("a^b", "ab|ba", input_symbols=input_symbols)) 158 | self.assertTrue( 159 | re.isequal( 160 | "ab^cd", 161 | "abcd | acbd | cabd | acdb | cadb | cdab", 162 | input_symbols=input_symbols, 163 | ) 164 | ) 165 | self.assertTrue( 166 | re.isequal("(a*)^(b*)^(c*)^(d*)", ".*", input_symbols=input_symbols) 167 | ) 168 | self.assertTrue( 169 | re.isequal("ca^db", "(c^db)a | (ca^d)b", input_symbols=input_symbols) 170 | ) 171 | self.assertTrue( 172 | re.isequal("a^(b|c)", "ab | ac | ba | ca", input_symbols=input_symbols) 173 | ) 174 | 175 | reference_nfa = NFA.from_regex("a*^ba") 176 | other_nfa = NFA.shuffle_product(NFA.from_regex("a*"), NFA.from_regex("ba")) 177 | self.assertEqual(reference_nfa, other_nfa) 178 | 179 | def test_quantifier(self) -> None: 180 | """Should correctly check quantifier""" 181 | 182 | input_symbols = {"a", "b", "c", "d"} 183 | 184 | # Simple equivalences 185 | self.assertTrue(re.isequal("a{1,3}", "a|aa|aaa", input_symbols=input_symbols)) 186 | self.assertTrue(re.isequal("a{5,5}", "aaaaa", input_symbols=input_symbols)) 187 | self.assertTrue(re.isequal("a{1,}", "a+", input_symbols=input_symbols)) 188 | self.assertTrue(re.isequal("a{0,}", "a*", input_symbols=input_symbols)) 189 | self.assertTrue(re.isequal("a{4,}", "aaaa+", input_symbols=input_symbols)) 190 | self.assertTrue( 191 | re.isequal("a{,4}", "a?|aa|aaa|aaaa", input_symbols=input_symbols) 192 | ) 193 | 194 | # More complex equivalences 195 | self.assertTrue(re.isequal("ba{,1}", "ba?", input_symbols=input_symbols)) 196 | self.assertTrue( 197 | re.isequal("(b|a){0,2}", "(a?)|b|ab|ba|bb|aa", input_symbols=input_symbols) 198 | ) 199 | self.assertTrue( 200 | re.isequal("(a*b|b*c*){0,1}", "(a*b|b*c*)?", input_symbols=input_symbols) 201 | ) 202 | self.assertTrue( 203 | re.isequal( 204 | "(aa^bb|ca^cb){0,}", "(aa^bb|ca^cb)*", input_symbols=input_symbols 205 | ) 206 | ) 207 | self.assertTrue( 208 | re.isequal( 209 | "(aa|bb^ca|cb){1,}", "(aa|bb^ca|cb)+", input_symbols=input_symbols 210 | ) 211 | ) 212 | 213 | # Tests for multiple quantifiers 214 | self.assertTrue( 215 | re.isequal("a{1,2}b{1,2}", "ab|aab|abb|aabb", input_symbols=input_symbols) 216 | ) 217 | self.assertTrue( 218 | re.isequal("a{2,2}(c*b){3,3}", "aac*bc*bc*b", input_symbols=input_symbols) 219 | ) 220 | self.assertTrue( 221 | re.isequal("a{2,2}ccb{3,3}", "aaccbbb", input_symbols=input_symbols) 222 | ) 223 | 224 | def test_blank(self) -> None: 225 | """Should correctly parse blank""" 226 | self.assertTrue(re.isequal("()", "")) 227 | self.assertTrue(re.isequal("a|()", "a?")) 228 | self.assertTrue(re.isequal("a()", "a")) 229 | self.assertTrue(re.isequal("a()b()()c()", "abc")) 230 | 231 | def test_invalid_symbols(self) -> None: 232 | """Should throw exception if reserved character is in input symbols""" 233 | with self.assertRaises(exceptions.InvalidSymbolError): 234 | NFA.from_regex("a+", input_symbols={"a", "+"}) 235 | -------------------------------------------------------------------------------- /tests/test_serialization.py: -------------------------------------------------------------------------------- 1 | """Tests for automata pickle serialization""" 2 | 3 | import pickle 4 | 5 | import tests.test_fa as test_fa 6 | 7 | 8 | class TestSerialization(test_fa.TestFA): 9 | """ 10 | This tests verifies that the cycle FA -> .pkl -> FA works. 11 | The test only applies to DFA and NFAs as other classes do not implement equality 12 | """ 13 | 14 | def test_serialize_dfa(self) -> None: 15 | """Should convert a DFA to pickle serialization and reads it back""" 16 | s = pickle.dumps(self.dfa) 17 | dfa = pickle.loads(s) 18 | self.assertEqual(self.dfa, dfa) 19 | 20 | def test_serialize_nfa(self) -> None: 21 | """Should convert a NFA to pickled representation and read it back""" 22 | s = pickle.dumps(self.nfa) 23 | nfa = pickle.loads(s) 24 | self.assertEqual(self.nfa, nfa) 25 | -------------------------------------------------------------------------------- /tests/test_tmconfiguration.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the behavior of PDAConfigurations.""" 2 | 3 | import tests.test_pda as test_pda 4 | from automata.tm.configuration import TMConfiguration 5 | from automata.tm.tape import TMTape 6 | 7 | 8 | class TestTMConfiguration(test_pda.TestPDA): 9 | """A test class for testing configurations of pushdown automata.""" 10 | 11 | def test_config_hashability(self) -> None: 12 | self.assertEqual( 13 | hash( 14 | TMConfiguration( 15 | "q0", TMTape("01", blank_symbol=".", current_position=0) 16 | ) 17 | ), 18 | hash( 19 | TMConfiguration( 20 | "q0", TMTape("01", blank_symbol=".", current_position=0) 21 | ) 22 | ), 23 | ) 24 | 25 | def test_config_repr(self) -> None: 26 | """Should create proper string representation of PDA configuration.""" 27 | config = TMConfiguration( 28 | "q0", TMTape("01", blank_symbol=".", current_position=0) 29 | ) 30 | self.assertEqual(repr(config), "TMConfiguration('q0', TMTape('01', '.', 0))") 31 | -------------------------------------------------------------------------------- /tests/test_tmtape.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the behavior of TMTapes.""" 2 | 3 | import unittest 4 | 5 | from automata.tm.tape import TMTape 6 | 7 | 8 | class TestTMTape(unittest.TestCase): 9 | """A test class for testing all Turing machines.""" 10 | 11 | def test_tape_copy(self) -> None: 12 | """Should copy TMTape.""" 13 | tape = TMTape("0011", blank_symbol="#", current_position=0) 14 | new_tape = tape.copy() 15 | self.assertIsNot(new_tape, tape) 16 | -------------------------------------------------------------------------------- /tests/test_tmtools.py: -------------------------------------------------------------------------------- 1 | """Classes and functions for testing the Turing machine utility functions.""" 2 | 3 | import contextlib 4 | import io 5 | import unittest 6 | from unittest.mock import MagicMock, call, patch 7 | 8 | import automata.tm.tools as tmtools 9 | from automata.tm.configuration import MTMConfiguration, TMConfiguration 10 | from automata.tm.tape import TMTape 11 | 12 | 13 | class TestTMTools(unittest.TestCase): 14 | """A test class for testing Turing machine utility functions.""" 15 | 16 | config: TMConfiguration 17 | config2: MTMConfiguration 18 | 19 | def setUp(self) -> None: 20 | """Provide a configuration for testing.""" 21 | self.config = TMConfiguration( 22 | "q2", 23 | TMTape( 24 | tape="abcdefghij", 25 | blank_symbol=".", 26 | current_position=2, 27 | ), 28 | ) 29 | 30 | self.config2 = MTMConfiguration( 31 | "q1", 32 | ( 33 | TMTape( 34 | tape="abcdefghij", 35 | blank_symbol=".", 36 | current_position=2, 37 | ), 38 | TMTape( 39 | tape="klmnopq", 40 | blank_symbol=".", 41 | current_position=5, 42 | ), 43 | ), 44 | ) 45 | 46 | def test_repr_config(self) -> None: 47 | """Should return a string representation ot the given configuration.""" 48 | self.assertEqual( 49 | repr(self.config), "TMConfiguration('q2', TMTape('abcdefghij', '.', 2))" 50 | ) 51 | self.assertEqual( 52 | repr(self.config2), 53 | "MTMConfiguration('q1', (TMTape('abcdefghij', '.', 2), " 54 | + "TMTape('klmnopq', '.', 5)))", 55 | ) 56 | 57 | def test_print_config(self) -> None: 58 | """Should print the given configuration to stdout.""" 59 | out = io.StringIO() 60 | with contextlib.redirect_stdout(out): 61 | self.config.print() 62 | self.assertEqual( 63 | out.getvalue().rstrip(), 64 | "{}: {}\n{}".format("q2", "abcdefghij", "^".rjust(7)), 65 | ) 66 | 67 | @patch("automata.tm.configuration.TMConfiguration.print") 68 | def test_print_configs(self, print_config: MagicMock) -> None: 69 | """Should print each machine configuration to stdout.""" 70 | tape1 = TMTape( 71 | tape="01010101", 72 | blank_symbol=".", 73 | current_position=0, 74 | ) 75 | tape2 = TMTape( 76 | tape="x1010101", 77 | blank_symbol=".", 78 | current_position=-1, 79 | ) 80 | tape3 = TMTape( 81 | tape="yx1010101", 82 | blank_symbol=".", 83 | current_position=-2, 84 | ) 85 | configs = [ 86 | TMConfiguration("q0", tape1), 87 | TMConfiguration("q1", tape2), 88 | TMConfiguration("q2", tape3), 89 | MTMConfiguration("q1", (tape1, tape2, tape3)), 90 | ] 91 | out = io.StringIO() 92 | with contextlib.redirect_stdout(out): 93 | tmtools.print_configs(configs) # type: ignore 94 | self.assertEqual(print_config.call_args_list, [call(), call(), call()]) 95 | 96 | def test_tape_iteration(self) -> None: 97 | """Should be able to iterate over a Turing machine tape.""" 98 | tape = TMTape( 99 | tape="abcdef", 100 | blank_symbol=".", 101 | current_position=2, 102 | ) 103 | self.assertEqual(tuple(tape), ("a", "b", "c", "d", "e", "f")) 104 | 105 | def test_get_symbols_as_str(self) -> None: 106 | """Should print tape contents as a string without spaces.""" 107 | tape = TMTape( 108 | tape="abcdef", 109 | blank_symbol=".", 110 | current_position=2, 111 | ) 112 | self.assertEqual(tape.get_symbols_as_str(), "abcdef") 113 | --------------------------------------------------------------------------------