├── .coveragerc ├── .flake8 ├── .git_archival.txt ├── .gitattributes ├── .github ├── CODEOWNERS └── workflows │ └── standard.yml ├── .gitignore ├── .gitmodules ├── .landscape.yml ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── conda-recipe └── meta.yaml ├── dev-requirements.txt ├── docs-requirements.txt ├── docs ├── Makefile └── source │ ├── _static │ └── .gitkeep │ ├── conf.py │ ├── getting_started.rst │ ├── index.rst │ ├── installation.rst │ ├── pragma_usage.rst │ ├── release_notes.rst │ ├── source_code.rst │ ├── terminal_usage.rst │ └── user_guide.rst ├── pyproject.toml ├── pytmc ├── __init__.py ├── __main__.py ├── beckhoff.py ├── bin │ ├── __init__.py │ ├── code.py │ ├── db.py │ ├── debug.py │ ├── iocboot.py │ ├── pragmalint.py │ ├── pytmc.py │ ├── stcmd.py │ ├── summary.py │ ├── template.py │ ├── types.py │ ├── util.py │ └── xmltranslate.py ├── code.py ├── default_settings │ ├── __init__.py │ ├── conf.ini │ └── unified_ordered_field_list.py ├── defaults.py ├── linter.py ├── parser.py ├── pragmas.py ├── record.py ├── templates │ ├── EPICS_proto_template.proto │ ├── asyn_standard_file.jinja2 │ ├── asyn_standard_record.jinja2 │ └── stcmd_default.cmd ├── tests │ ├── __init__.py │ ├── ads.dbd │ ├── conftest.py │ ├── static_routes.xml │ ├── templates │ │ ├── basic_test.txt │ │ └── smoke_test.txt │ ├── test_archive.py │ ├── test_commandline.py │ ├── test_integrations.py │ ├── test_lint.py │ ├── test_motion.py │ ├── test_parsing.py │ ├── test_project.py │ ├── test_record.py │ ├── test_xml_collector.py │ ├── test_xml_obj.py │ └── tmc_files │ │ ├── ArbiterPLC.tmc │ │ ├── tc_mot_example.tmc │ │ └── xtes_sxr_plc.tmc ├── validation │ ├── __init__.py │ └── v0.1.py └── version.py └── requirements.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | pytmc 4 | [report] 5 | omit = 6 | #versioning 7 | .*version.* 8 | *_version.py 9 | #tests 10 | *test* 11 | pytmc/tests/* 12 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,__pycache__,build,dist,pytmc/_version.py 3 | max-line-length = 88 4 | select = C,E,F,W,B,B950 5 | extend-ignore = E203, E501, E226, W503, W504 6 | 7 | # Explanation section: 8 | # B950 9 | # This takes into account max-line-length but only triggers when the value 10 | # has been exceeded by more than 10% (96 characters). 11 | # E203: Whitespace before ':' 12 | # This is recommended by black in relation to slice formatting. 13 | # E501: Line too long (82 > 79 characters) 14 | # Our line length limit is 88 (above 79 defined in E501). Ignore it. 15 | # E226: Missing whitespace around arithmetic operator 16 | # This is a stylistic choice which you'll find everywhere in pcdsdevices, for 17 | # example. Formulas can be easier to read when operators and operands 18 | # have no whitespace between them. 19 | # 20 | # W503: Line break occurred before a binary operator 21 | # W504: Line break occurred after a binary operator 22 | # flake8 wants us to choose one of the above two. Our choice 23 | # is to make no decision. 24 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: 94c62dbb0584aba3f3e38ef14ac5186a7425e5ca 2 | node-date: 2025-05-16T17:02:25-07:00 3 | describe-name: v2.18.2-2-g94c62dbb 4 | ref-names: HEAD -> master 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # default group 2 | * @pcdshub/python-reviewers @pcdshub/twincat-reviewers 3 | 4 | # language-specific group(s) 5 | ## PYTHON files 6 | *.py @pcdshub/python-reviewers 7 | *.pyi @pcdshub/python-reviewers 8 | *.pyc @pcdshub/python-reviewers 9 | *.pyw @pcdshub/python-reviewers 10 | *.pyx @pcdshub/python-reviewers 11 | *.pyd @pcdshub/python-reviewers 12 | ## TWINCAT files 13 | *.tsproj @pcdshub/twincat-reviewers 14 | *.plcproj @pcdshub/twincat-reviewers 15 | *.tmc @pcdshub/twincat-reviewers 16 | *.tpr @pcdshub/twincat-reviewers 17 | *.xti @pcdshub/twincat-reviewers 18 | *.TcTTO @pcdshub/twincat-reviewers 19 | *.TcPOU @pcdshub/twincat-reviewers 20 | *.TcDUT @pcdshub/twincat-reviewers 21 | *.TcGVL @pcdshub/twincat-reviewers 22 | *.TcVis @pcdshub/twincat-reviewers 23 | *.TcVMO @pcdshub/twincat-reviewers 24 | *.TcGTLO @pcdshub/twincat-reviewers 25 | 26 | # github folder holds administrative files 27 | .github/** @pcdshub/software-admin 28 | -------------------------------------------------------------------------------- /.github/workflows/standard.yml: -------------------------------------------------------------------------------- 1 | name: PCDS Standard Testing 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: 8 | - published 9 | 10 | jobs: 11 | standard: 12 | uses: pcdshub/pcds-ci-helpers/.github/workflows/python-standard.yml@master 13 | secrets: inherit 14 | with: 15 | # The workflow needs to know the package name. This can be determined 16 | # automatically if the repository name is the same as the import name. 17 | package-name: "pytmc" 18 | # Extras that will be installed for both conda/pip: 19 | testing-extras: "" 20 | # Extras to be installed only for conda-based testing: 21 | conda-testing-extras: "" 22 | # Extras to be installed only for pip-based testing: 23 | pip-testing-extras: "" 24 | # Set if using setuptools-scm for the conda-build workflow 25 | use-setuptools-scm: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Custom 2 | *.swp 3 | *.swo 4 | *~ 5 | pytmc/tests/tmc_files/*.db 6 | pytmc/tests/tmc_files/invalid/*.db 7 | ./*.db 8 | pytmc/_version.py 9 | 10 | .DS_store 11 | *.pyc 12 | .vscode 13 | docs/build/* 14 | .coverage 15 | *.cover 16 | .cache 17 | *.egg 18 | *.egg-info/ 19 | htmlcov 20 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/projects/lcls-plc-kfe-xgmd-vac"] 2 | path = pytmc/tests/projects/lcls-plc-kfe-xgmd-vac 3 | url = https://github.com/pcdshub/lcls-plc-kfe-xgmd-vac.git 4 | [submodule "tests/projects/lcls-twincat-motion"] 5 | path = pytmc/tests/projects/lcls-twincat-motion 6 | url = https://github.com/pcdshub/lcls-twincat-motion.git 7 | [submodule "tests/projects/pmps-dev-arbiter"] 8 | path = pytmc/tests/projects/pmps-dev-arbiter 9 | url = https://github.com/pcdshub/pmps-dev-arbiter.git 10 | [submodule "tests/projects/lcls-twincat-pmps"] 11 | path = pytmc/tests/projects/lcls-twincat-pmps 12 | url = https://github.com/pcdshub/lcls-twincat-pmps.git 13 | [submodule "tests/projects/lcls-plc-kfe-gmd-vac-ext-io"] 14 | path = pytmc/tests/projects/lcls-plc-kfe-gmd-vac-ext-io 15 | url = https://github.com/n-wbrown/lcls-plc-kfe-gmd-vac.git 16 | [submodule "pytmc/tests/projects/lcls-plc-lfe-motion"] 17 | path = pytmc/tests/projects/lcls-plc-lfe-motion 18 | url = https://github.com/pcdshub/lcls-plc-lfe-motion 19 | -------------------------------------------------------------------------------- /.landscape.yml: -------------------------------------------------------------------------------- 1 | doc-warnings: true 2 | test-warnings: false 3 | pylint: 4 | disable: 5 | - protected-access 6 | - too-many-arguments 7 | strictness: medium 8 | max-line-length: 80 9 | autodetect: true 10 | ignore-paths: 11 | - docs 12 | - __init__.py 13 | python-targets: 14 | - 3 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: | 4 | (?x)^( 5 | .*\.tmc| 6 | .*\.TcPOU| 7 | pytmc/_version.py| 8 | )$ 9 | 10 | repos: 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v4.4.0 13 | hooks: 14 | - id: no-commit-to-branch 15 | - id: trailing-whitespace 16 | - id: end-of-file-fixer 17 | - id: check-ast 18 | - id: check-case-conflict 19 | - id: check-json 20 | - id: check-merge-conflict 21 | - id: check-symlinks 22 | - id: check-xml 23 | - id: check-yaml 24 | exclude: '^(conda-recipe/meta.yaml)$' 25 | - id: debug-statements 26 | exclude: '^(pytmc/bin/util.py)$' 27 | 28 | - repo: https://github.com/pycqa/flake8.git 29 | rev: 6.0.0 30 | hooks: 31 | - id: flake8 32 | 33 | - repo: https://github.com/timothycrosley/isort 34 | rev: 5.12.0 35 | hooks: 36 | - id: isort 37 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Maintainer 6 | ---------- 7 | 8 | * SLAC National Accelerator Laboratory <> 9 | 10 | Contributors 11 | ------------ 12 | 13 | Interested? See: `CONTRIBUTING.rst `_ 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every little bit 6 | helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/pcdshub/pytmc/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Any details about your local setup that might be helpful in troubleshooting. 21 | * Detailed steps to reproduce the bug. 22 | 23 | Fix Bugs 24 | ~~~~~~~~ 25 | 26 | Look through the GitHub issues for bugs. Anything tagged with "bug" 27 | is open to whoever wants to implement it. 28 | 29 | Implement Features 30 | ~~~~~~~~~~~~~~~~~~ 31 | 32 | Look through the GitHub issues for features. Anything tagged with "feature" 33 | is open to whoever wants to implement it. 34 | 35 | Write Documentation 36 | ~~~~~~~~~~~~~~~~~~~ 37 | 38 | pytmc could always use more documentation, whether 39 | as part of the official pytmc docs, in docstrings, 40 | or even on the web in blog posts, articles, and such. 41 | 42 | Submit Feedback 43 | ~~~~~~~~~~~~~~~ 44 | 45 | The best way to send feedback is to file an issue at https://github.com/pcdshub/pytmc/issues. 46 | 47 | If you are proposing a feature: 48 | 49 | * Explain in detail how it would work. 50 | * Keep the scope as narrow as possible, to make it easier to implement. 51 | * Remember that this is a volunteer-driven project, and that contributions 52 | are welcome :) 53 | 54 | Get Started! 55 | ------------ 56 | 57 | Ready to contribute? Here's how to set up `pytmc` for local development. 58 | 59 | 1. Fork the `pytmc` repo on GitHub. 60 | 2. Clone your fork locally:: 61 | 62 | $ git clone git@github.com:your_name_here/pytmc.git 63 | 64 | 3. Install your local copy into a new conda environment. Assuming you have conda installed, this is how you set up your fork for local development:: 65 | 66 | $ conda create -n pytmc python=3.9 pip 67 | $ cd pytmc/ 68 | $ pip install -e . 69 | 70 | 4. Create a branch for local development:: 71 | 72 | $ git checkout -b name-of-your-bugfix-or-feature 73 | 74 | Now you can make your changes locally. 75 | 76 | 5. Install and enable ``pre-commit`` for this repository:: 77 | 78 | $ pip install pre-commit 79 | $ pre-commit install 80 | 81 | 6. Add new tests for any additional functionality or bugs you may have discovered. And, of course, be sure that all previous tests still pass by running:: 82 | 83 | $ pytest -v 84 | 85 | 7. Commit your changes and push your branch to GitHub:: 86 | 87 | $ git add . 88 | $ git commit -m "Your detailed description of your changes." 89 | $ git push origin name-of-your-bugfix-or-feature 90 | 91 | 8. Submit a pull request through the GitHub website. 92 | 93 | Pull Request Guidelines 94 | ----------------------- 95 | 96 | Before you submit a pull request, check that it meets these guidelines: 97 | 98 | 1. The pull request should include tests. 99 | 2. If the pull request adds functionality, the docs should be updated. Put your 100 | new functionality into a function with a docstring, and add the feature to 101 | the list in README.rst. 102 | 3. The pull request should work for Python 3.9 and up. Check the GitHub Actions status 103 | and make sure that the tests pass for all supported Python versions. 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023, The Board of Trustees of the Leland Stanford Junior 2 | University, through SLAC National Accelerator Laboratory (subject to receipt 3 | of any required approvals from the U.S. Dept. of Energy). All rights reserved. 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | (1) Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | (2) Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | (3) Neither the name of the Leland Stanford Junior University, SLAC National 15 | Accelerator Laboratory, U.S. Dept. of Energy nor the names of its 16 | contributors may be used to endorse or promote products derived from this 17 | software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER, THE UNITED STATES GOVERNMENT, 23 | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 24 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT 25 | OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 27 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 28 | IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY 29 | OF SUCH DAMAGE. 30 | 31 | You are under no obligation whatsoever to provide any bug fixes, patches, or 32 | upgrades to the features, functionality or performance of the source code 33 | ("Enhancements") to anyone; however, if you choose to make your Enhancements 34 | available either publicly, or directly to SLAC National Accelerator Laboratory, 35 | without imposing a separate written license agreement for such Enhancements, 36 | then you hereby grant the following license: a non-exclusive, royalty-free 37 | perpetual license to install, use, modify, prepare derivative works, incorporate 38 | into other computer software, distribute, and sublicense such Enhancements or 39 | derivative works thereof, in binary and source code form. 40 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include versioneer.py 2 | include requirements.txt 3 | include README.rst 4 | include pytmc/templates/* 5 | include pytmc/_version.py 6 | include LICENSE 7 | recursive-include pytmc/tests *.txt 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PYTMC 2 | ===== 3 | 4 | .. image:: https://travis-ci.org/pcdshub/pytmc.svg?branch=master 5 | :target: https://travis-ci.org/pcdshub/pytmc 6 | 7 | Generate Epics DB records from TwinCAT .tmc files. 8 | 9 | See the `docs `_ for more information. 10 | 11 | =============== ===== 12 | Compatibility 13 | ====================== 14 | python 3.6, 3.7 .. image:: https://travis-ci.org/pcdshub/pytmc.svg?branch=master 15 | :target: https://travis-ci.org/pcdshub/pytmc 16 | =============== ===== 17 | -------------------------------------------------------------------------------- /conda-recipe/meta.yaml: -------------------------------------------------------------------------------- 1 | {% set package_name = "pytmc" %} 2 | {% set import_name = "pytmc" %} 3 | {% set version = load_file_regex(load_file=os.path.join(import_name, "_version.py"), regex_pattern=".*version = '(\S+)'").group(1) %} 4 | 5 | package: 6 | name: {{ package_name }} 7 | version: {{ version }} 8 | 9 | source: 10 | path: .. 11 | 12 | build: 13 | number: 0 14 | noarch: python 15 | script: {{ PYTHON }} -m pip install . -vv 16 | 17 | requirements: 18 | host: 19 | - python >=3.9 20 | - pip 21 | - setuptools_scm 22 | run: 23 | - python >=3.9 24 | - jinja2 25 | - lxml 26 | - epics-pypdb >=0.1.5 27 | run_constrained: 28 | - pyqt =5 29 | 30 | test: 31 | imports: 32 | - pytmc 33 | requires: 34 | - pytest 35 | - pytest-qt 36 | - qtpy 37 | - pyqt =5 38 | 39 | about: 40 | doc_url: https://pcdshub.github.io/pytmc/ 41 | home: https://github.com/pcdshub/pytmc 42 | license: SLAC Open 43 | license_family: Other 44 | license_file: LICENSE 45 | summary: Generate EPICS IOCs and records from Beckhoff TwinCAT projects 46 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt5 2 | pytest 3 | pytest-cov 4 | pytest-qt 5 | qtpy 6 | -------------------------------------------------------------------------------- /docs-requirements.txt: -------------------------------------------------------------------------------- 1 | docs-versions-menu 2 | sphinx 3 | sphinx-argparse 4 | sphinx-autodoc-typehints 5 | sphinx_rtd_theme>=1.2.0 6 | sphinxcontrib-websupport 7 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = pytmc 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcdshub/pytmc/94c62dbb0584aba3f3e38ef14ac5186a7425e5ca/docs/source/_static/.gitkeep -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # pytmc documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Dec 7 12:54:14 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import datetime 20 | import os 21 | import sys 22 | 23 | module_path = os.path.join( 24 | os.path.dirname(os.path.abspath(__file__)), 25 | os.pardir, 26 | ) 27 | 28 | sys.path.insert(0, module_path) 29 | 30 | # -- General configuration ------------------------------------------------ 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | # 34 | # needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be 37 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 38 | # ones. 39 | extensions = [ 40 | "docs_versions_menu", 41 | "sphinx.ext.autodoc", 42 | "sphinx.ext.githubpages", 43 | "sphinx.ext.mathjax", 44 | "sphinx.ext.napoleon", 45 | "sphinx.ext.todo", 46 | "sphinx_autodoc_typehints", 47 | "sphinxarg.ext", 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # The suffix(es) of source filenames. 54 | # You can specify multiple suffix as a list of string: 55 | # 56 | # source_suffix = ['.rst', '.md'] 57 | source_suffix = ".rst" 58 | 59 | # The master toctree document. 60 | master_doc = "index" 61 | 62 | # General information about the project. 63 | project = "pytmc" 64 | author = "SLAC National Accelerator Laboratory" 65 | year = datetime.datetime.now().year 66 | copyright = f"{year}, {author}" 67 | 68 | # The version info for the project you're documenting, acts as replacement for 69 | # |version| and |release|, also used in various other places throughout the 70 | # built documents. 71 | # 72 | # The short X.Y version. 73 | version = "" 74 | # The full version, including alpha/beta/rc tags. 75 | release = "" 76 | 77 | # The language for content autogenerated by Sphinx. Refer to documentation 78 | # for a list of supported languages. 79 | # 80 | # This is also used if you do content translation via gettext catalogs. 81 | # Usually you set "language" from the command line for these cases. 82 | language = "en" 83 | 84 | # List of patterns, relative to source directory, that match files and 85 | # directories to ignore when looking for source files. 86 | # This patterns also effect to html_static_path and html_extra_path 87 | exclude_patterns = ["build", "Thumbs.db", ".DS_Store"] 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = "sphinx" 91 | 92 | # If true, `todo` and `todoList` produce output, else they produce nothing. 93 | todo_include_todos = True 94 | 95 | 96 | # -- Options for HTML output ---------------------------------------------- 97 | 98 | # The theme to use for HTML and HTML Help pages. See the documentation for 99 | # a list of builtin themes. 100 | # 101 | html_theme = "sphinx_rtd_theme" 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | # 107 | # html_theme_options = {} 108 | 109 | # Add any paths that contain custom static files (such as style sheets) here, 110 | # relative to this directory. They are copied after the builtin static files, 111 | # so a file named "default.css" will overwrite the builtin "default.css". 112 | html_static_path = ["_static"] 113 | 114 | # Custom sidebar templates, must be a dictionary that maps document names 115 | # to template names. 116 | # 117 | # This is required for the alabaster theme 118 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 119 | html_sidebars = { 120 | "**": [ 121 | "relations.html", # needs 'show_related': True theme option to display 122 | "searchbox.html", 123 | ] 124 | } 125 | 126 | 127 | # -- Options for HTMLHelp output ------------------------------------------ 128 | 129 | # Output file base name for HTML help builder. 130 | htmlhelp_basename = "pytmcdoc" 131 | 132 | 133 | # -- Options for LaTeX output --------------------------------------------- 134 | 135 | latex_elements = { 136 | # The paper size ('letterpaper' or 'a4paper'). 137 | # 138 | # 'papersize': 'letterpaper', 139 | # The font size ('10pt', '11pt' or '12pt'). 140 | # 141 | # 'pointsize': '10pt', 142 | # Additional stuff for the LaTeX preamble. 143 | # 144 | # 'preamble': '', 145 | # Latex figure (float) alignment 146 | # 147 | # 'figure_align': 'htbp', 148 | } 149 | 150 | # Grouping the document tree into LaTeX files. List of tuples 151 | # (source start file, target name, title, 152 | # author, documentclass [howto, manual, or own class]). 153 | latex_documents = [ 154 | ( 155 | master_doc, 156 | "pytmc.tex", 157 | "pytmc Documentation", 158 | "SLAC National Accelerator Laboratory", 159 | "manual", 160 | ), 161 | ] 162 | 163 | 164 | # -- Options for manual page output --------------------------------------- 165 | 166 | # One entry per manual page. List of tuples 167 | # (source start file, name, description, authors, manual section). 168 | man_pages = [(master_doc, "pytmc", "pytmc Documentation", [author], 1)] 169 | 170 | 171 | # -- Options for Texinfo output ------------------------------------------- 172 | 173 | # Grouping the document tree into Texinfo files. List of tuples 174 | # (source start file, target name, title, author, 175 | # dir menu entry, description, category) 176 | texinfo_documents = [ 177 | ( 178 | master_doc, 179 | "pytmc", 180 | "pytmc Documentation", 181 | author, 182 | "pytmc", 183 | "", 184 | "Miscellaneous", 185 | ), 186 | ] 187 | -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | General Usage 5 | +++++++++++++ 6 | pytmc has various capabilities that can be accessed using the top-level ``pytmc`` program: 7 | 8 | 1. Generating EPICS database files (.db) based on a Beckhoff TwinCAT ``.tmc`` file (``pytmc db``) 9 | 2. Introspecting ``.tmc`` files for their symbols and data types ( ``pytmc debug`` and ``pytmc types``) 10 | 3. Generating full EPICS IOCs based on a provided template (``pytmc iocboot`` and ``pytmc stcmd``) 11 | 4. Parsing, introspecting, and summarizing full TwinCAT projects (``pytmc summary``) 12 | 5. Outlining any TwinCAT XML file (``pytmc xmltranslate``) 13 | 14 | In order for pytmc to work properly, the TwinCAT project and its 15 | libraries require annotation. The resulting IOC depends upon EPICS ADS driver. This 16 | driver is provided by the `European Spallation Source 17 | `_ and is hosted on their `bitbucket page 18 | `_. 19 | 20 | Marking the TwinCAT project 21 | +++++++++++++++++++++++++++ 22 | Marking the TwinCAT project determines how the EPICS record will be generated. 23 | The marking process uses custom attribute pragmas to designate variables for 24 | pytmc to process. The pragma should be applied just above the declaration of 25 | the variable you wish to mark. You can read more about the TwinCAT pragma 26 | system `here 27 | `_. 28 | 29 | Best practices for SLAC projects are documented in the `PCDS confluence page 30 | `_. 31 | 32 | Having issues with multiline pragmas and related things? See the `PCDS flight 33 | rules 34 | `_. 35 | 36 | 37 | Python Usage 38 | ++++++++++++ 39 | Once installed pytmc and its components can be imported into a python program 40 | or shell like any normal python package. Consult the source code documentation 41 | for specifics. 42 | 43 | 44 | Full IOC Creation 45 | +++++++++++++++++ 46 | 47 | PCDS has recently focused its efforts on `ads-deploy 48 | `_, for seamless development and 49 | deployment of IOCs directly from the TwinCAT IDE. Behind the scenes, ads-deploy 50 | takes care of the following steps using a docker container: 51 | 52 | .. code-block:: sh 53 | 54 | $ git clone https://github.com/pcdshub/ads-ioc /path/to/ads-ioc 55 | $ pytmc iocboot /path/to/plc.tsproj /path/to/ads-ioc/iocBoot/templates/ 56 | # Creates directories: ioc-plc-name1, ioc-plc-name2, ... 57 | # Creates Makefiles: ioc-plc-name/Makefile 58 | 59 | $ cd ioc-plc-name 60 | $ vim Makefile 61 | # Customize - if necessary - to add more db files or change pytmc stcmd options 62 | 63 | $ make 64 | # Shells out to pytmc stcmd; 65 | # Creates st.cmd, ioc-plc-name.db, envPaths 66 | 67 | $ ./st.cmd 68 | # Runs IOC 69 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. pytmc documentation master file, created by 2 | sphinx-quickstart on Thu Dec 7 12:54:14 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to pytmc's documentation! 7 | ================================= 8 | .. image:: https://travis-ci.org/pcdshub/pytmc.svg?branch=master 9 | :target: https://travis-ci.org/pcdshub/pytmc 10 | 11 | Pytmc helps developers automatically generate ADS based Epics records files 12 | from Beckhoff's TwinCAT3 projects. 13 | 14 | Pytmc is developed with these `guidelines 15 | `_ in mind. 16 | 17 | The source code is hosted on `github `_. 18 | 19 | Issues and requests can be posted on the `github issue tracker 20 | `_. Use the ``bug`` tag for issues 21 | with intended features and the ``enhancement`` tag can be used for feature 22 | requests. The ``question`` tag will be treated like an 'enhancements' tag for 23 | the documentation but regular questions can be posted there as well. 24 | 25 | .. toctree:: 26 | :maxdepth: 4 27 | :caption: Contents: 28 | 29 | user_guide.rst 30 | source_code.rst 31 | release_notes.rst 32 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Obtaining the code 5 | ++++++++++++++++++ 6 | Download the code via the tagged releases posted on the `github releases page 7 | `_ or by cloning the source code 8 | with the following: 9 | 10 | .. code-block:: sh 11 | 12 | $ git clone https://github.com/pcdshub/pytmc.git 13 | 14 | Installing in an environment 15 | ++++++++++++++++++++++++++++ 16 | Create a python virtual environment using conda and install the pytmc in that 17 | environment. 18 | 19 | Begin by creating an environment and replacing [env-name] with the 20 | name of your environment. If you're installing pytmc in a preexisting 21 | environment, you may skip this step. 22 | 23 | .. code-block:: sh 24 | 25 | $ conda create --name [env-name] 26 | 27 | Activate your environment. 28 | 29 | .. code-block:: sh 30 | 31 | $ source activate [env-name] 32 | 33 | Install pip in the current environment if it is not already. If pip is not 34 | installed in your environment, the system will default to using pip in the root 35 | environment. When the root environment's version of pip is used. Pip will 36 | attempt to install the package in the root envirnoment as well. 37 | 38 | .. code-block:: sh 39 | 40 | $ conda install pip 41 | 42 | After cloning or unzipping the package, navigate to the base directory of 43 | pytmc. There you will find a file titled ``setup.py`` and another titles 44 | ``requirements.txt``. Run the following commands from this directory. Make sure 45 | to install pip in this conda environment prior to installing tools with pip. 46 | Using pip in an environment lacking a pip installation will install pytmc in 47 | your root environment. 48 | 49 | .. code-block:: sh 50 | 51 | $ # Install pytmc's dependencies 52 | $ pip install -r requirements.txt 53 | $ # Install pytmc to your environment 54 | $ pip install . 55 | 56 | .. note:: 57 | 58 | The last line in the code snippet above has a '.' at the end. It is very 59 | difficult to see with certain browsers. 60 | 61 | 62 | Testing the installation 63 | ++++++++++++++++++++++++ 64 | If you've followed the previous steps correctly, pytmc should be installed now. 65 | This can be tested by seeing if the following bash commands can be found. 66 | 67 | .. code-block:: sh 68 | 69 | $ pytmc --help 70 | 71 | Alternatively, a python shell can be opened and you can attempt to import 72 | pytmc. 73 | 74 | .. code-block:: python 75 | 76 | >>> import pytmc 77 | 78 | .. note:: 79 | While all of these instructions should work with python environments 80 | managed by virtualenv and pipenv, only conda has been tested. 81 | 82 | 83 | Installing for development 84 | ++++++++++++++++++++++++++ 85 | To develop pytmc it is best to use a development install. This allows changes 86 | to the code to be immediately reflected in the program's functionality without 87 | needing to reinstall the code.This can be done by following the `Installing in 88 | an environment`_ section but with one change. The following code snippet should 89 | be removed: 90 | 91 | .. code-block:: sh 92 | 93 | $ # Don't use this step for a development install 94 | $ pip install . 95 | 96 | In place of the removed command, use the following to do a development install. 97 | 98 | .. code-block:: sh 99 | 100 | $ # Use this command instead 101 | $ pip install -e . 102 | -------------------------------------------------------------------------------- /docs/source/release_notes.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Release History 3 | ================= 4 | 5 | v2.18.2 (2025-02-12) 6 | ==================== 7 | 8 | Changes 9 | ------- 10 | - Add support for TwinCAT 4026 structures that store certain xti files in folders 11 | adjacent to their locations in 4024. 12 | 13 | 14 | v2.18.1 (2025-02-11) 15 | ==================== 16 | 17 | Due to an error, no functional changes in this tag. Please use the next tag instead. 18 | 19 | 20 | v2.18.0 (2024-12-19) 21 | ==================== 22 | 23 | Changes 24 | ------- 25 | - Adds support for arrays of strings. Previously, using a pv pragma on an array of 26 | strings would result in pytmc attempting and failing to create waveform record with 27 | FTVL set to STRING, which was specifically disallowed. Now it will create a separate 28 | string pv for each element of the array, similar to arrays of enums or complex types 29 | 30 | Maintenance 31 | ----------- 32 | - Add `setuptools_scm` to conda recipe build section 33 | 34 | 35 | v2.17.0 (2024-09-16) 36 | ==================== 37 | 38 | This version fixes an issue where there was no way for `ads-ioc` to enforce 39 | read-only behavior on the `_RBV` variants. This led to confusing behavior 40 | because the IOC will accept these writes and not respond to them in an 41 | intuitive way. 42 | 43 | `pytmc` will now generate input/rbv records as having the `NO_WRITE` ASG. 44 | This will affect all PVs that represent data read from the PLC code. It will not affect the setpoints. 45 | 46 | `ads-ioc` can now implement a `NO_WRITE` ASG and it will be applied to all of these PVs. 47 | This is in `ads-ioc` at `R0.7.0`. 48 | 49 | 50 | v2.16.0 (2023-07-31) 51 | ==================== 52 | 53 | Changes 54 | ------- 55 | 56 | * ``pytmc template``, which takes a TwinCAT project and jinja template source 57 | code to generate project-specific output, now expects all platforms to use 58 | the same delimiter (``":"``) for template filename patterns. Examples include: 59 | 60 | * Read template from ``a.template`` and write expanded version to ``a.txt``: 61 | ``pytmc template my.tsproj --template a.template:a.txt`` 62 | * Read template from ``a.template`` and write results to standard output: 63 | ``pytmc template my.tsproj --template a.template`` 64 | * Read template from standard input and write results to standard output: 65 | ``pytmc template my.tsproj --template -`` 66 | * Read template from standard input and write results to ``/path/to/a.txt``: 67 | ``pytmc template my.tsproj --template -:/path/to/a.txt`` 68 | 69 | * Extended support for projects not correctly configured in TwinCAT with 70 | "Independent Files" for all supported options. Generating EPICS IOCs 71 | from such projects that also include NC axes should succeed with 72 | a number of loud warnings to the user. 73 | 74 | Maintenance 75 | ----------- 76 | 77 | * Fixed old release note syntax. 78 | 79 | 80 | v2.15.1 (2023-06-30) 81 | ==================== 82 | 83 | Bugfixes 84 | -------- 85 | - Type aliases will now find pytmc pragmas defined on their base types. 86 | Previously these were ignored. 87 | - ST_MotionStage is now the canonical name for the motor struct, 88 | matching our twincat style guide. Backwards compatibility is retained 89 | for projects using DUT_MotionStage. 90 | - Fix an issue where macro substitution did not load properly for 91 | motor base PVs in the st.cmd file generation. 92 | - Fix an issue where the version could fail to load in an edge case 93 | where a git clone was included via symbolic link. 94 | 95 | Maintenance 96 | ----------- 97 | - Ensure workflow secrets are used properly. 98 | - Fix issues related to documention building on the Github actions CI. 99 | 100 | 101 | v2.15.0 (2023-04-04) 102 | ==================== 103 | 104 | Python 3.9 is now the minimum supported version for pytmc. 105 | 106 | Maintenance 107 | ----------- 108 | * Fixes pre-commit repository settings for flake8. 109 | * Migrates from Travis CI to GitHub Actions for continuous integration testing, and documentation deployment. 110 | * Updates pytmc to use setuptools-scm, replacing versioneer, as its version-string management tool of choice. 111 | * Syntax has been updated to Python 3.9+ via ``pyupgrade``. 112 | * pytmc has migrated to modern ``pyproject.toml``, replacing ``setup.py``. 113 | * Sphinx 6.0 now supported for documentation building. 114 | * ``docs-versions-menu`` is now used for documentation deployment on GitHub Actions. 115 | 116 | 117 | v2.14.1 (2022-09-28) 118 | ==================== 119 | 120 | This release doesn't change any behavior of the library, but it does fix an error in the test suite that causes false failures. 121 | 122 | Maintenance 123 | ----------- 124 | 125 | * TST: test suite was using old kwarg, swap to new by @ZLLentz in #296 126 | 127 | 128 | v2.14.0 (2022-08-29) 129 | ==================== 130 | 131 | Fixes 132 | ----- 133 | * Safety PLC files loaded from `_Config/SPLC` by @klauer in https://github.com/pcdshub/pytmc/pull/289 134 | 135 | Enhancements 136 | ------------ 137 | 138 | * Sort generated records by TwinCAT symbol name (tcname) by @klauer in https://github.com/pcdshub/pytmc/pull/293 139 | * The order of records in EPICS process database (`.db`) files will change for most users after this release. After the initial rebuild, users should expect to see smaller diffs on subsequent PLC project rebuilds. 140 | * Add all hooks required to allow transition of pytmc stcmd -> template by @klauer in https://github.com/pcdshub/pytmc/pull/290 141 | 142 | * Adds helper commands to `pytmc template`, which can be used in Jinja templates: 143 | 144 | * `generate_records` (create .db and .archive files) 145 | * `get_plc_by_name` 146 | * `get_symbols_by_type` 147 | 148 | * Adds variables to `pytmc template` environment, which can be used in Jinja templates: `pytmc_version` 149 | 150 | * Adds `--macro` option to `pytmc template` 151 | * Fixes some annotations + uncovered/untested functionality 152 | * Allows `pytmc template` to read/write multiple templates with parsing a project only once 153 | 154 | 155 | v2.13.0 (2022-06-30) 156 | ==================== 157 | 158 | Enhancements 159 | ------------ 160 | * ENH: autosave field additions by @klauer in https://github.com/pcdshub/pytmc/pull/287 161 | * Adds description field to autosave for all records, input and output 162 | * Adds alarm severity and limit fields to autosave for all relevant input and output records 163 | * Adds control limit (drive low/high) fields to autosave for relevant output records 164 | 165 | v2.12.0 (2022-05-27) 166 | ==================== 167 | 168 | Fixes 169 | ----- 170 | * CP link instead of CPP link by @klauer in https://github.com/pcdshub/pytmc/pull/283 171 | 172 | Maintenance 173 | ----------- 174 | * Address CI-related failures and update pre-commit settings by @klauer in https://github.com/pcdshub/pytmc/pull/285 175 | 176 | 177 | v2.11.1 (2022-03-24) 178 | ==================== 179 | 180 | Maintenance 181 | ----------- 182 | 183 | * CLN: remove evalcontextfilter usage by @klauer in #280 184 | * Jinja2 3.1 compatibility fix 185 | * TST: does linking work as expected? by @klauer in #279 186 | * Additional tests 187 | 188 | v2.11.0 (2021-11-15) 189 | ==================== 190 | 191 | Enhancements 192 | ------------ 193 | * Add ``EnumerationTextList`` with ``get_source_code`` support. 194 | Previously, these translatable types were missing. 195 | * Add actions, methods, and properties to the ``pytmc code`` output. 196 | * Allow for ``pytmc code`` to work with just a single code object, 197 | rather than requiring the whole project. 198 | * Add ``pytmc.__main__`` such that 199 | ``python -m pytmc {code,summary} ...`` works. 200 | 201 | Fixes 202 | ----- 203 | * Fix rare bug in `lines_between` function, probably never hit. 204 | 205 | Maintenance 206 | ----------- 207 | * Type annotation cleanups and fixes 208 | * Reduce memory consumption slightly by not caching the xml element 209 | on every `TwincatItem` 210 | 211 | 212 | v2.10.0 (2021-08-09) 213 | ==================== 214 | 215 | Enhancements 216 | ------------ 217 | * Allow strings to be linked using the ``link:`` pragma key. Previously, 218 | this was only implemented for numeric scalar values. 219 | 220 | 221 | v2.9.1 (2021-04-27) 222 | =================== 223 | 224 | Enhancements 225 | ------------ 226 | * Added ``scale`` and ``offset`` pragma keys for integer and floating point 227 | symbols. 228 | 229 | Maintenance 230 | ----------- 231 | * Fixed remaining ``slaclab`` references, after the repository was moved to 232 | ``pcdshub``. 233 | 234 | 235 | v2.9.0 (2021-04-02) 236 | =================== 237 | 238 | Enhancements 239 | ------------ 240 | * Add git information to the template tool if available. 241 | 242 | 243 | v2.8.1 (2021-02-10) 244 | =================== 245 | 246 | Fixes 247 | ----- 248 | * Fix issues related to insufficient library dependency checking. Now, 249 | all possible places where library version information is stored will 250 | be checked. 251 | 252 | Maintenance 253 | ----------- 254 | * Refactor the dependency-related twincat items and templating tools 255 | to accomplish the above. 256 | * Move the repository landing zone from slaclab to pcdshub to take 257 | advantage of our travis credits. 258 | * Redeploy doctr for pcdshub. 259 | 260 | 261 | v2.8.0 (2020-12-22) 262 | =================== 263 | 264 | Enhancements 265 | ------------ 266 | 267 | * Add support for externally adding pragmas to members of structures and 268 | function blocks. 269 | * Add support for partial pragmas of array elements. 270 | * Added text filter in ``pytmc debug`` dialog. 271 | * Check maximum record length when generating the database file. This is a 272 | constant defined at epics-base compile time, defaulting to 60. 273 | 274 | Fixes 275 | ----- 276 | 277 | * Record names now displaying correctly in ``pytmc debug`` dialog. 278 | * ``pytmc debug`` no longer fails when it encounters types that extend 279 | built-in data types by way of ``ExtendsType``. 280 | 281 | 282 | v2.7.7 (2020-11-17) 283 | =================== 284 | 285 | Fixes 286 | ----- 287 | * Fix issue with pass1 autosave not appropriately writing values to the PLC 288 | on IOC startup. 289 | 290 | Maintenance 291 | ----------- 292 | * Regenerate doctr deploy key. 293 | 294 | 295 | v2.7.6 (2020-10-23) 296 | =================== 297 | 298 | Fixes 299 | ----- 300 | * Added handling for case where pragma is None 301 | * Lower array archive threshold to arrays with fewer than 1000 elements 302 | to prevent our high-rate encoder and power meter readbacks. This is a good 303 | threshold because it represents 1000Hz data with a 1Hz polling rate, a 304 | very typical parameter. 305 | * Default APST and MPST fields to "On Change" for waveform PVs. These are 306 | special waveform fields that tell monitors and the archiver when to take an 307 | update, and previously they were set to "Always", causing influxes of data 308 | from static char waveform strings. 309 | 310 | Maintenance 311 | ----------- 312 | * Split dev/docs requirements 313 | * Fix jinja naming 314 | 315 | 316 | v2.7.5 (2020-08-31) 317 | =================== 318 | 319 | Fixes 320 | ----- 321 | 322 | * Relaxed end-of-pragma-line handling (any combination of ``;`` and newline are 323 | all accepted). 324 | * Reworked XTI file loading for "devices" and "boxes". This aims to be more 325 | compatible with TwinCAT, which does not always relocate XTI files to be in 326 | the correct hierarchical directory location. It pre-loads all XTI files, and 327 | when the project is fully loaded, it dereferences XTI files based on a key 328 | including ``class``, ``filename``, and a small PLC-unique ``identifier``. 329 | * Better handling of data types in the project parser. Now supports data type 330 | GUIDs, when available, for data type disambiguation. Note that these are not 331 | always present. 332 | * Better handling of references, pointers, and pointer depth. 333 | 334 | Development 335 | ----------- 336 | 337 | * ``pytmc db --debug`` allows developers to more easily target exceptions 338 | raised when generating database files. 339 | * Added more memory layout information for the benefit of other utilities such 340 | as ``ads-async``. Its ADS server implementation in conjunction with pytmc may 341 | be a good source of information regarding PLC memory layout in the future. 342 | * Started adding some annotations for clarity. May retroactively add more as 343 | time permits. 344 | 345 | 346 | v2.7.1 (2020-08-18) 347 | =================== 348 | 349 | Fixes 350 | ----- 351 | 352 | * Working fix for macro expansion character replacement for linked PVs 353 | (``DOL`` field). This means ``link: @(MACRO)PV`` now works. 354 | * Tests will no longer be installed erroneously as a package on the system. 355 | 356 | Development 357 | ----------- 358 | 359 | * Tests have been moved into the pytmc package, and with it flake8 compliance. 360 | 361 | 362 | v2.7.0 (2020-07-16) 363 | =================== 364 | 365 | * Included an incomplete fix for macro expansion character replacement for 366 | linked PVs (``DOL`` field) 367 | 368 | 369 | v2.6.9 (2020-07-06) 370 | =================== 371 | 372 | * Fixes pragmalint bug that fails on an empty declaration section 373 | 374 | 375 | v2.6.8 (2020-07-06) 376 | =================== 377 | 378 | * Fixes issue where qtpy/pyqt not being installed may cause ``pytmc`` 379 | command-line tools to fail 380 | 381 | 382 | v2.6.7 (2020-07-02) 383 | =================== 384 | 385 | * Project-level data type summary 386 | * Create DataArea for data type summary if unavailable in .tmc 387 | 388 | 389 | v2.6.6 (2020-06-24) 390 | =================== 391 | 392 | * Add –types (–filter-types) to ``pytmc summary`` 393 | (`#213 `__) 394 | * Fix internal usage of deprecated API 395 | (`#212 `__) 396 | 397 | 398 | v2.6.5 (2020-06-09) 399 | =================== 400 | 401 | * Add ``info(archive)`` nodes for ads-ioc 402 | (`#188 `__) 403 | * Adjust defaults for binary record enum strings 404 | (`#191 `__) 405 | * Better messages on pragma parsing failures 406 | (`#200 `__) 407 | * Do not include fields only intended for input/output records in the 408 | other (`#205 `__) 409 | * (Development) Fix package manifest and continuous integration 410 | 411 | 412 | v2.6.0 (2020-02-26) 413 | =================== 414 | 415 | * Fix FB_MotionStage pointer-handling in st.cmd generation 416 | * Fix off-by-one array bounds error 417 | * Expose actions in summary + generate more readable code block output 418 | * Fix autosave info node names 419 | * Ensure ``--allow-errors`` is passed along to the database generation 420 | step when using ``pytmc stcmd`` 421 | * Allow ``pytmc db`` to work with the ``.tsproj`` file along with 422 | ``.tmc`` file 423 | * Add initial “PV linking” functionality (to be completed + documented; 424 | paired with lcls-twincat-general) 425 | * Fix bug where Enum info may be missing from the .tmc file 426 | * Show the chain name of a failed record generation attempt 427 | * Fix loading of ``_Config/IO`` files in certain cases, though there is 428 | still work to be done here 429 | (`#187 `__ 430 | 431 | 432 | v2.5.0 (2019-12-20) 433 | =================== 434 | 435 | Features 436 | -------- 437 | 438 | * Debug tool option for showing variables which do not generate records (`#159 `__) “incomplete pragmas/chains” 439 | * Automatic generation of archive support files (`#162 `__) 440 | * Support customization of update rates via poll/notify (`#151 `__), looking forward to new m-epics-twincat-ads releases 441 | * Support record aliases (`#150 `__) 442 | * Description defaults to PLC variable path if unspecified (`#152 `__) 443 | 444 | Fixes 445 | ----- 446 | * Ordering of autosave fields (`#154 `__) 447 | * Box summary ordering (`#164 `__) 448 | * Allow alternative character for EPICS macros (default ``@``) 449 | * Documentation updates + pragma key clarification 450 | 451 | 452 | v2.4.0 (2019-12-06) 453 | =================== 454 | 455 | Features 456 | -------- 457 | 458 | * Pinned global variables are supported 459 | * Autosave support 460 | * Pypi integration 461 | 462 | Enhancements 463 | ------------ 464 | 465 | * Linter/Debugger improvements 466 | * Debug shows relative paths 467 | 468 | Fixes 469 | ----- 470 | 471 | * Record sorting is now deterministic 472 | 473 | Pull requests incorporated 474 | -------------------------- 475 | 476 | * `#130 `__ 477 | * `#135 `__ 478 | * `#137 `__ 479 | * `#138 `__ 480 | * `#141 `__ 481 | * `#142 `__ 482 | * `#143 `__ 483 | * `#144 `__ 484 | 485 | 486 | v2.3.1 (2019-11-08) 487 | =================== 488 | 489 | Fixes 490 | ----- 491 | 492 | * Fixed an issue where Enums weren’t being handled correctly 493 | * pytmc now allows the declaration/implementation to be ``None`` allowing these 494 | sections to be empty without breaking 495 | * Some windows file reading issues have been resolved 496 | 497 | Refactors 498 | --------- 499 | * Move pragma checking code to from ``Datatype.walk`` to ``SubItem.walk`` for 500 | an implementation more consistent with ``Symbol.walk`` 501 | 502 | 503 | v2.3.0 (2019-10-28) 504 | =================== 505 | 506 | PRs 507 | --- 508 | * `#123 `__, 509 | * `#124 `__, and 510 | * `#125 `__ to an official release. 511 | 512 | Features 513 | -------- 514 | * Add Support For NC axis parameters 515 | * ``.sln`` files may now be passed to ``pytmc summary`` 516 | 517 | Fixes 518 | ----- 519 | * ``pytmc`` now identifies and handles T_MaxString 520 | 521 | 522 | v2.2.0 (2019-09-20) 523 | =================== 524 | 525 | Enhancements 526 | ------------ 527 | 528 | * Adds support for arrays of complex datatypes. 529 | * Replaces FB_MotionStage support with DUT_MotionStage. 530 | * Converts ’_’ in project name in TC3 to ‘-’ in ioc name following convention. 531 | 532 | Fixes 533 | ----- 534 | 535 | * ``stcmd`` generation updated to match changes to ``pragmas`` functionality solving some incompatibilites 536 | * Switch to DUT_MotionStage namespace allows motors above 0-9 range. 537 | 538 | 539 | v2.1.0 (2019-09-05) 540 | =================== 541 | 542 | This tag includes the new pragma linting features for assessing whether 543 | TwinCAT3 projects are PyTMC compatible. 544 | 545 | This feature can be accessed using this command: 546 | ``pytmc pragmalint [-h] [--markdown] [--verbose] filename`` 547 | 548 | 549 | v1.1.2 (2019-03-15) 550 | =================== 551 | 552 | Features 553 | -------- 554 | 555 | * Pragmas can now be delimited with semicolons # Bugfixes 556 | * Spaces after the first semicolon in a pragma no longer break pragmas 557 | * Blank PV strings no longer lead to the creation of multiple colons in 558 | a PV name 559 | * Single line pragmas are properly recognized now 560 | 561 | 562 | v1.1.1 (2019-02-14) 563 | =================== 564 | 565 | This release rectifies several issues with the command line interface. 566 | The primary command is now ``pytmc`` replacing the old ``makerecord``. 567 | 568 | Tests for python 3.7 have been implemented. 569 | 570 | 571 | v1.1.0 (2018-10-16) 572 | =================== 573 | 574 | Incorporate support for a greater set of TwinCAT Datatypes. 575 | 576 | 577 | v1.0.0 (2018-09-24) 578 | =================== 579 | 580 | First major release. 581 | 582 | 583 | v0.1 (2018-03-02) 584 | ================= 585 | 586 | Primary features of .db and .proto file creation have been implemented. 587 | Compatibility with enums, aliases, waveforms/arrays, field guessing 588 | tools, and a user guide have not been implemented. 589 | -------------------------------------------------------------------------------- /docs/source/source_code.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | pytmc.pragmas 5 | ------------- 6 | 7 | .. automodule:: pytmc.pragmas 8 | :show-inheritance: 9 | :members: 10 | 11 | 12 | pytmc.record 13 | ------------- 14 | 15 | .. automodule:: pytmc.record 16 | :show-inheritance: 17 | :members: 18 | 19 | 20 | pytmc.parser 21 | ------------- 22 | 23 | .. automodule:: pytmc.parser 24 | :show-inheritance: 25 | :members: 26 | 27 | 28 | pytmc.linter 29 | ------------- 30 | 31 | .. automodule:: pytmc.linter 32 | :show-inheritance: 33 | :members: 34 | -------------------------------------------------------------------------------- /docs/source/terminal_usage.rst: -------------------------------------------------------------------------------- 1 | Command-line Usage 2 | ================== 3 | 4 | Using pytmc 5 | ----------- 6 | Once pytmc has been installed in a virtual environment the ``pytmc`` command 7 | can be called from the command line to generate .db files and more. 8 | 9 | 10 | pytmc db 11 | -------- 12 | 13 | .. argparse:: 14 | :module: pytmc.bin.db 15 | :func: build_arg_parser 16 | :prog: pytmc db 17 | 18 | 19 | pytmc stcmd 20 | ----------- 21 | 22 | .. argparse:: 23 | :module: pytmc.bin.stcmd 24 | :func: build_arg_parser 25 | :prog: pytmc stcmd 26 | 27 | 28 | pytmc xmltranslate 29 | ------------------ 30 | 31 | .. argparse:: 32 | :module: pytmc.bin.xmltranslate 33 | :func: build_arg_parser 34 | :prog: pytmc xmltranslate 35 | 36 | 37 | pytmc debug 38 | ----------- 39 | 40 | .. argparse:: 41 | :module: pytmc.bin.debug 42 | :func: build_arg_parser 43 | :prog: pytmc debug 44 | 45 | 46 | pytmc pragmalint 47 | ---------------- 48 | 49 | .. argparse:: 50 | :module: pytmc.bin.pragmalint 51 | :func: build_arg_parser 52 | :prog: pytmc pragmalint 53 | 54 | 55 | pytmc stcmd 56 | ----------- 57 | 58 | .. argparse:: 59 | :module: pytmc.bin.stcmd 60 | :func: build_arg_parser 61 | :prog: pytmc stcmd 62 | 63 | 64 | pytmc summary 65 | ------------- 66 | 67 | .. argparse:: 68 | :module: pytmc.bin.summary 69 | :func: build_arg_parser 70 | :prog: pytmc summary 71 | 72 | 73 | pytmc template 74 | -------------- 75 | 76 | .. argparse:: 77 | :module: pytmc.bin.template 78 | :func: build_arg_parser 79 | :prog: pytmc template 80 | 81 | pytmc types 82 | ----------- 83 | 84 | .. argparse:: 85 | :module: pytmc.bin.types 86 | :func: build_arg_parser 87 | :prog: pytmc types 88 | 89 | 90 | Templates 91 | ========= 92 | 93 | stcmd_default.cmd 94 | ----------------- 95 | 96 | .. include:: ../../pytmc/templates/stcmd_default.cmd 97 | :literal: 98 | 99 | asyn_standard_file.jinja2 100 | ------------------------- 101 | 102 | .. include:: ../../pytmc/templates/asyn_standard_file.jinja2 103 | :literal: 104 | 105 | 106 | asyn_standard_record.jinja2 107 | --------------------------- 108 | 109 | .. include:: ../../pytmc/templates/asyn_standard_record.jinja2 110 | :literal: 111 | -------------------------------------------------------------------------------- /docs/source/user_guide.rst: -------------------------------------------------------------------------------- 1 | User Guide 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :caption: Contents: 7 | 8 | getting_started.rst 9 | installation.rst 10 | pragma_usage.rst 11 | terminal_usage.rst 12 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ "setuptools>=45", "setuptools_scm[toml]>=6.2",] 4 | 5 | [project] 6 | classifiers = [ "Development Status :: 5 - Production/Stable", "Natural Language :: English", "Programming Language :: Python :: 3",] 7 | description = "Generate Epics DB records from TwinCAT .tmc files" 8 | dynamic = [ "version", "readme", "dependencies", "optional-dependencies", ] 9 | keywords = [] 10 | name = "pytmc" 11 | requires-python = ">=3.9" 12 | [[project.authors]] 13 | name = "SLAC National Accelerator Laboratory" 14 | 15 | [options] 16 | zip_safe = false 17 | include_package_data = true 18 | 19 | [project.license] 20 | file = "LICENSE" 21 | 22 | [project.scripts] 23 | pytmc = "pytmc.bin.pytmc:main" 24 | 25 | [tool.setuptools_scm] 26 | write_to = "pytmc/_version.py" 27 | 28 | [tool.setuptools.packages.find] 29 | where = [ ".",] 30 | include = [ "pytmc*",] 31 | namespaces = false 32 | 33 | [tool.setuptools.dynamic.readme] 34 | file = "README.rst" 35 | 36 | [tool.setuptools.dynamic.dependencies] 37 | file = [ "requirements.txt",] 38 | 39 | [tool.setuptools.dynamic.optional-dependencies.test] 40 | file = "dev-requirements.txt" 41 | 42 | [tool.setuptools.dynamic.optional-dependencies.doc] 43 | file = "docs-requirements.txt" 44 | 45 | [tool.pytest.ini_options] 46 | addopts = "--cov=." 47 | -------------------------------------------------------------------------------- /pytmc/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from . import linter, parser, pragmas 4 | from .record import EPICSRecord, RecordPackage 5 | from .version import __version__ # noqa: F401 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | __all__ = [ 11 | "EPICSRecord", 12 | "RecordPackage", 13 | "linter", 14 | "logger", 15 | "parser", 16 | "pragmas", 17 | ] 18 | -------------------------------------------------------------------------------- /pytmc/__main__.py: -------------------------------------------------------------------------------- 1 | from .bin.pytmc import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /pytmc/beckhoff.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | 5 | 6 | beckhoff_types = [ 7 | "BOOL", 8 | "BYTE", 9 | "WORD", 10 | "DWORD", 11 | "SINT", 12 | "USINT", 13 | "INT", 14 | "UINT", 15 | "DINT", 16 | "UDINT", 17 | "LINT", 18 | "ULINT", 19 | "REAL", 20 | "LREAL", 21 | "STRING", 22 | "TIME", 23 | "TIME_OF_DAY", 24 | "TOD", # unclear if this is the xml abbreviation for TIME_OF_DAY 25 | "DATE", 26 | "DATE_AND_TIME", 27 | "DT", # unclear if this is the xml abbreviation for DATE_AND_TIME 28 | ] 29 | -------------------------------------------------------------------------------- /pytmc/bin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcdshub/pytmc/94c62dbb0584aba3f3e38ef14ac5186a7425e5ca/pytmc/bin/__init__.py -------------------------------------------------------------------------------- /pytmc/bin/code.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc code" is a command line utility for extracting the source code of 3 | TwinCAT3 .tsproj projects. 4 | """ 5 | 6 | import argparse 7 | import pathlib 8 | 9 | from .. import parser 10 | 11 | DESCRIPTION = __doc__ 12 | 13 | 14 | def build_arg_parser(argparser=None): 15 | if argparser is None: 16 | argparser = argparse.ArgumentParser() 17 | 18 | argparser.description = DESCRIPTION 19 | argparser.formatter_class = argparse.RawTextHelpFormatter 20 | 21 | argparser.add_argument( 22 | "filename", type=str, help="Path to project (.tsproj) or source code filename" 23 | ) 24 | 25 | return argparser 26 | 27 | 28 | def dump_source_code(filename): 29 | "Return the source code for a given tsproj project" 30 | proj_path = pathlib.Path(filename) 31 | 32 | if proj_path.suffix.lower() not in (".tsproj",): 33 | parsed = parser.parse(proj_path) 34 | code_containers = [ 35 | item 36 | for item in parsed.find(parser.TwincatItem, recurse=False) 37 | if hasattr(item, "get_source_code") 38 | ] 39 | source = "\n\n".join( 40 | code_container.get_source_code() or "" for code_container in code_containers 41 | ) 42 | return parsed, source 43 | 44 | project = parser.parse(proj_path) 45 | full_source = [] 46 | 47 | for plc in project.plcs: 48 | source_items = ( 49 | list(plc.dut_by_name.items()) 50 | + list(plc.gvl_by_name.items()) 51 | + list(plc.pou_by_name.items()) 52 | ) 53 | for name, source in source_items: 54 | if hasattr(source, "get_source_code"): 55 | source_text = source.get_source_code() or "" 56 | if source_text.strip(): 57 | full_source.append(source_text) 58 | 59 | return project, "\n\n".join(full_source) 60 | 61 | 62 | def main(filename): 63 | """ 64 | Output the source code of a project given its filename. 65 | """ 66 | 67 | results = {} 68 | path = pathlib.Path(filename) 69 | 70 | project, source_code = dump_source_code(path) 71 | print(source_code) 72 | results[project] = source_code 73 | return results 74 | -------------------------------------------------------------------------------- /pytmc/bin/db.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc-db" is a command line utility for generating epics records files from 3 | TwinCAT3 .tmc files. This program is designed to work in conjunction with ESS's 4 | m-epics-twincat-ads driver. 5 | """ 6 | 7 | import argparse 8 | import logging 9 | import os 10 | import pathlib 11 | import sys 12 | from collections import defaultdict 13 | from typing import Optional 14 | 15 | from .. import linter, parser 16 | from ..pragmas import find_pytmc_symbols, record_packages_from_symbol 17 | from ..record import RecordPackage, generate_archive_settings 18 | 19 | logger = logging.getLogger(__name__) 20 | DESCRIPTION = __doc__ 21 | 22 | 23 | class LinterError(Exception): 24 | ... 25 | 26 | 27 | def validate_with_dbd(packages, dbd_file, remove_invalid_fields=True, **linter_options): 28 | """ 29 | Validate all to-be-generated record fields 30 | 31 | Parameters 32 | ---------- 33 | packages : list 34 | List of RecordPackage 35 | dbd_file : str or DbdFile 36 | The dbd file with which to validate 37 | remove_invalid_fields : bool, optional 38 | Remove fields marked by the linter as invalid 39 | **linter_options : dict 40 | Options to pass to the linter 41 | 42 | Returns 43 | ------- 44 | pytmc.linter.LinterResults 45 | Results from the linting process 46 | 47 | Raises 48 | ------ 49 | DBSyntaxError 50 | If db/dbd processing fails 51 | 52 | See also 53 | -------- 54 | pytmc.linter.lint_db 55 | """ 56 | db_text = "\n\n".join(record.render() for record in packages) 57 | results = linter.lint_db(dbd=dbd_file, db=db_text, **linter_options) 58 | if remove_invalid_fields: 59 | all_invalid_fields = [ 60 | error["format_args"] 61 | for error in results.errors 62 | if error["name"] == "bad-field" and len(error["format_args"]) == 2 63 | ] 64 | invalid_fields_by_record = defaultdict(set) 65 | for record_type, field_name in all_invalid_fields: 66 | invalid_fields_by_record[record_type].add(field_name) 67 | 68 | for pack in packages: 69 | for record in getattr(pack, "records", []): 70 | for field in invalid_fields_by_record.get(record.record_type, []): 71 | pack.config["field"].pop(field, None) 72 | return results, db_text 73 | 74 | 75 | def process( 76 | tmc: parser.TcModuleClass, 77 | *, 78 | dbd_file: Optional[parser.AnyPath] = None, 79 | allow_errors: bool = False, 80 | show_error_context: bool = True, 81 | allow_no_pragma: bool = False, 82 | debug: bool = False, 83 | ) -> tuple[list[RecordPackage], list[Exception]]: 84 | """ 85 | Process a TMC 86 | 87 | Parameters 88 | ---------- 89 | tmc : TcModuleClass 90 | The parsed TMC file instance. 91 | dbd_file : str or DbdFile, optional 92 | The dbd file with which to validate 93 | allow_errors : bool, optional 94 | Allow processing to continue even with errors. 95 | show_error_context : bool, optional 96 | Show context from the database file around the error. 97 | allow_no_pragma : bool, optional 98 | Allow intermediate objects to not have a pytmc pragma and still regard 99 | it as valid, assuming at least the first and last item have pragmas. 100 | debug : bool, optional 101 | Debug mode. 102 | 103 | Returns 104 | ------- 105 | records : list 106 | List of RecordPackage 107 | exceptions : list 108 | List of exceptions raised during parsing 109 | 110 | Raises 111 | ------ 112 | LinterError 113 | If ``allow_errors`` is unset and an error is found. 114 | """ 115 | 116 | def _show_context_from_line(rendered, from_line): 117 | lines = list(enumerate(rendered.splitlines()[: from_line + 1], 1)) 118 | context = [] 119 | for line_num, line in reversed(lines): 120 | context.append((line_num, line)) 121 | if line.lstrip().startswith("record"): 122 | break 123 | 124 | context.reverse() 125 | for line_num, line in context: 126 | logger.error(" [db:%d] %s", line_num, line) 127 | return context 128 | 129 | records = [ 130 | record 131 | for symbol in find_pytmc_symbols(tmc, allow_no_pragma=allow_no_pragma) 132 | for record in record_packages_from_symbol( 133 | symbol, yield_exceptions=not debug, allow_no_pragma=allow_no_pragma 134 | ) 135 | ] 136 | 137 | exceptions = [ex for ex in records if isinstance(ex, Exception)] 138 | 139 | for ex in exceptions: 140 | logger.error("Error creating record: %s", ex) 141 | records.remove(ex) 142 | 143 | if exceptions and not allow_errors: 144 | raise LinterError("Failed to create database") 145 | 146 | # We removed exceptions from the record list above: 147 | records: list[RecordPackage] 148 | 149 | def by_tcname(record: RecordPackage): 150 | return record.tcname 151 | 152 | records.sort(key=by_tcname) 153 | 154 | record_names = [ 155 | single_record.pvname 156 | for record_package in records 157 | if record_package.valid 158 | for single_record in record_package.records 159 | ] 160 | 161 | if len(record_names) != len(set(record_names)): 162 | dupes = { 163 | name: record_names.count(name) 164 | for name in record_names 165 | if record_names.count(name) > 1 166 | } 167 | message = "\n".join( 168 | ["Duplicate records encountered:"] 169 | + [f" {dupe} ({count})" for dupe, count in sorted(dupes.items())] 170 | ) 171 | 172 | ex = LinterError(message) 173 | if not allow_errors: 174 | raise ex 175 | exceptions.append(ex) 176 | logger.error(message) 177 | 178 | too_long = [ 179 | record_name 180 | for record_name in record_names 181 | if len(record_name) > linter.MAX_RECORD_LENGTH 182 | ] 183 | if too_long: 184 | message = "\n".join( 185 | [ 186 | f"Records exceeding the maximum length of " 187 | f"{linter.MAX_RECORD_LENGTH} were found:" 188 | ] 189 | + [f" {record}" for record in too_long] 190 | ) 191 | 192 | ex = LinterError(message) 193 | if not allow_errors: 194 | raise ex 195 | exceptions.append(ex) 196 | logger.error(message) 197 | 198 | if dbd_file is not None: 199 | results, rendered = validate_with_dbd(records, dbd_file) 200 | for warning in results.warnings: 201 | logger.warning( 202 | "[%s line %s] %s", warning["file"], warning["line"], warning["message"] 203 | ) 204 | for error in results.errors: 205 | logger.error( 206 | "[%s line %s] %s", error["file"], error["line"], error["message"] 207 | ) 208 | if "Can't change record" in error["message"]: 209 | logger.error( 210 | "[%s line %s] One or more pragmas result in the " 211 | "same generated PV name. This must be fixed.", 212 | error["file"], 213 | error["line"], 214 | ) 215 | 216 | if show_error_context: 217 | _show_context_from_line(rendered, error["line"]) 218 | if not results.success and not allow_errors: 219 | raise LinterError("Failed to create database") 220 | 221 | return records, exceptions 222 | 223 | 224 | def build_arg_parser(parser=None): 225 | if parser is None: 226 | parser = argparse.ArgumentParser() 227 | 228 | parser.description = DESCRIPTION 229 | parser.formatter_class = argparse.RawTextHelpFormatter 230 | 231 | parser.add_argument( 232 | "--dbd", 233 | "-d", 234 | default=None, 235 | type=str, 236 | help=( 237 | "Specify an expanded .dbd file for validating fields " "(requires pyPDB)" 238 | ), 239 | ) 240 | 241 | parser.add_argument( 242 | "--allow-errors", 243 | "-i", 244 | action="store_true", 245 | default=False, 246 | help="Generate the .db file even if linter issues are found", 247 | ) 248 | 249 | parser.add_argument( 250 | "--no-error-context", 251 | action="store_true", 252 | default=False, 253 | help="Do not show db file context around errors", 254 | ) 255 | 256 | parser.add_argument( 257 | "--plc", 258 | default=None, 259 | type=str, 260 | help="The PLC name, if specifying a .tsproj file", 261 | ) 262 | 263 | parser.add_argument( 264 | "--debug", 265 | action="store_true", 266 | help=( 267 | "Raise exceptions immediately, such that the IPython debugger " 268 | "may be used" 269 | ), 270 | ) 271 | 272 | archive_group = parser.add_mutually_exclusive_group() 273 | archive_group.add_argument( 274 | "--archive-file", 275 | type=argparse.FileType("wt", encoding="ascii"), 276 | help=( 277 | "Save an archive configuration file. Defaults to " 278 | "OUTPUT.archive if specified" 279 | ), 280 | ) 281 | 282 | archive_group.add_argument( 283 | "--no-archive-file", 284 | action="store_true", 285 | default=False, 286 | help=( 287 | "Do not write the archive file, regardless of OUTPUT " "filename settings." 288 | ), 289 | ) 290 | 291 | parser.add_argument( 292 | "tmc_file", 293 | metavar="INPUT", 294 | type=str, 295 | help="Path to interpreted .tmc file, or a .tsproj project", 296 | ) 297 | 298 | class OutputFileAction(argparse.Action): 299 | def __call__(self, parser, namespace, value, option_string=None): 300 | if namespace.no_archive_file or not os.path.exists(value.name): 301 | namespace.archive_file = None 302 | else: 303 | namespace.archive_file = open(value.name + ".archive", "w") 304 | namespace.record_file = value 305 | 306 | parser.add_argument( 307 | "record_file", 308 | metavar="OUTPUT", 309 | action=OutputFileAction, 310 | type=argparse.FileType("wt", encoding="ascii"), 311 | default=sys.stdout, 312 | nargs="?", 313 | help="Path to output .db file", 314 | ) 315 | 316 | return parser 317 | 318 | 319 | def main( 320 | tmc_file, 321 | record_file=sys.stdout, 322 | *, 323 | dbd=None, 324 | allow_errors=False, 325 | no_error_context=False, 326 | archive_file=None, 327 | no_archive_file=False, 328 | plc=None, 329 | debug=False, 330 | ): 331 | if archive_file and no_archive_file: 332 | raise ValueError( 333 | "Invalid options specified (specify zero or one of " 334 | "archive_file or no_archive_file)" 335 | ) 336 | 337 | proj_path = pathlib.Path(tmc_file) 338 | if proj_path.suffix.lower() == ".tsproj": 339 | project = parser.parse(proj_path) 340 | if plc is None: 341 | try: 342 | (plc_inst,) = project.plcs 343 | except TypeError: 344 | raise RuntimeError( 345 | "A .tsproj file was specified without --plc. Available " 346 | "PLCs: " + ", ".join(plc.name for plc in project.plcs) 347 | ) 348 | plc = plc_inst.name 349 | tmc_file = project.plcs_by_name[plc].tmc_path 350 | 351 | tmc = parser.parse(tmc_file) 352 | 353 | try: 354 | records, exceptions = process( 355 | tmc, 356 | dbd_file=dbd, 357 | allow_errors=allow_errors, 358 | show_error_context=not no_error_context, 359 | debug=debug, 360 | ) 361 | except LinterError: 362 | logger.exception( 363 | "Linter errors - failed to create database. To create the database" 364 | " ignoring these errors, use the flag `--allow-errors`" 365 | ) 366 | sys.exit(1) 367 | 368 | db_string = "\n\n".join(record.render() for record in records) 369 | record_file.write(db_string) 370 | 371 | if archive_file: 372 | archive_file.write("\n".join(generate_archive_settings(records))) 373 | -------------------------------------------------------------------------------- /pytmc/bin/debug.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc-debug" is a Qt interface that shows information about how pytmc 3 | interprets TwinCAT3 .tmc files. 4 | """ 5 | 6 | import argparse 7 | import logging 8 | import pathlib 9 | import sys 10 | 11 | from qtpy import QtWidgets 12 | from qtpy.QtCore import Qt, Signal 13 | 14 | import pytmc 15 | 16 | from .db import process 17 | 18 | DESCRIPTION = __doc__ 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | def _grep_record_names(text): 23 | if not text: 24 | return [] 25 | 26 | records = [ 27 | line.rstrip("{ ") 28 | for line in text.splitlines() 29 | if line.startswith("record") # regular line 30 | or line.startswith(". record") # linted line 31 | or line.startswith("X record") # linted error line 32 | ] 33 | 34 | def split_rtyp(line): 35 | line = line.split("record(", 1)[1].rstrip('")') 36 | rtyp, record = line.split(",", 1) 37 | record = record.strip("\"' ") 38 | return f"{record} ({rtyp})" 39 | 40 | return [split_rtyp(record) for record in records] 41 | 42 | 43 | def _annotate_record_text(linter_results, record_text): 44 | if not record_text: 45 | return record_text 46 | if not linter_results or not (linter_results.warnings or linter_results.errors): 47 | return record_text 48 | 49 | lines = [([], line) for line in record_text.splitlines()] 50 | 51 | for item in linter_results.warnings + linter_results.errors: 52 | try: 53 | lint_md, line = lines[item["line"] - 1] 54 | except IndexError: 55 | continue 56 | 57 | if item in linter_results.warnings: 58 | lint_md.append("X Warning: {}".format(item["message"])) 59 | else: 60 | lint_md.append("X Error: {}".format(item["message"])) 61 | 62 | display_lines = [] 63 | for lint_md, line in lines: 64 | if not lint_md: 65 | display_lines.append(f". {line}") 66 | else: 67 | display_lines.append(f"X {line}") 68 | for lint_line in lint_md: 69 | display_lines.append(lint_line) 70 | 71 | return "\n".join(display_lines) 72 | 73 | 74 | class TmcSummary(QtWidgets.QMainWindow): 75 | """ 76 | pytmc debug interface 77 | 78 | Parameters 79 | ---------- 80 | tmc : TmcFile 81 | The tmc file to inspect 82 | """ 83 | 84 | item_selected = Signal(object) 85 | 86 | def __init__(self, tmc, dbd, allow_no_pragma=False): 87 | super().__init__() 88 | self.tmc = tmc 89 | self.chains = {} 90 | self.incomplete_chains = {} 91 | self.records = {} 92 | 93 | records, self.exceptions = process( 94 | tmc, allow_errors=True, allow_no_pragma=allow_no_pragma 95 | ) 96 | 97 | for record in records: 98 | if not record.valid: 99 | self.incomplete_chains[record.tcname] = record 100 | continue 101 | 102 | self.chains[record.tcname] = record 103 | try: 104 | record_text = record.render() 105 | linter_results = ( 106 | pytmc.linter.lint_db(dbd, record_text) 107 | if dbd and record_text 108 | else None 109 | ) 110 | record_text = _annotate_record_text(linter_results, record_text) 111 | except Exception as ex: 112 | try: 113 | record_text 114 | except NameError: 115 | record_text = "Record not rendered" 116 | 117 | record_text = ( 118 | f"!! Linter failure: {ex.__class__.__name__} {ex}" 119 | f"\n\n{record_text}" 120 | ) 121 | 122 | logger.exception("Linter failure") 123 | 124 | self.records[record] = record_text 125 | 126 | self.setWindowTitle(f"pytmc-debug summary - {tmc.filename}") 127 | 128 | self._mode = "chains" 129 | 130 | # Left part of the window 131 | self.left_frame = QtWidgets.QFrame() 132 | self.left_layout = QtWidgets.QVBoxLayout() 133 | self.left_frame.setLayout(self.left_layout) 134 | 135 | self.item_view_type = QtWidgets.QComboBox() 136 | self.item_view_type.addItem("Chains") 137 | self.item_view_type.addItem("Records") 138 | self.item_view_type.addItem("Chains w/o Records") 139 | self.item_view_type.currentTextChanged.connect(self._update_view_type) 140 | self.item_list = QtWidgets.QListWidget() 141 | self.item_list_filter = QtWidgets.QLineEdit() 142 | 143 | self.left_layout.addWidget(self.item_view_type) 144 | self.left_layout.addWidget(self.item_list_filter) 145 | self.left_layout.addWidget(self.item_list) 146 | 147 | # Right part of the window 148 | self.right_frame = QtWidgets.QFrame() 149 | self.right_layout = QtWidgets.QVBoxLayout() 150 | self.right_frame.setLayout(self.right_layout) 151 | 152 | self.record_text = QtWidgets.QTextEdit() 153 | self.record_text.setFontFamily("Courier New") 154 | self.chain_info = QtWidgets.QListWidget() 155 | self.config_info = QtWidgets.QTableWidget() 156 | 157 | self.right_layout.addWidget(self.record_text) 158 | self.right_layout.addWidget(self.chain_info) 159 | self.right_layout.addWidget(self.config_info) 160 | 161 | self.frame_splitter = QtWidgets.QSplitter() 162 | self.frame_splitter.setOrientation(Qt.Horizontal) 163 | self.frame_splitter.addWidget(self.left_frame) 164 | self.frame_splitter.addWidget(self.right_frame) 165 | 166 | self.top_splitter = self.frame_splitter 167 | if self.exceptions: 168 | self.error_list = QtWidgets.QTextEdit() 169 | self.error_list.setReadOnly(True) 170 | 171 | for ex in self.exceptions: 172 | self.error_list.append(f"({ex.__class__.__name__}) {ex}\n") 173 | 174 | self.error_splitter = QtWidgets.QSplitter() 175 | self.error_splitter.setOrientation(Qt.Vertical) 176 | self.error_splitter.addWidget(self.frame_splitter) 177 | self.error_splitter.addWidget(self.error_list) 178 | self.top_splitter = self.error_splitter 179 | 180 | self.setCentralWidget(self.top_splitter) 181 | self.item_list.currentItemChanged.connect(self._item_selected) 182 | 183 | self.item_selected.connect(self._update_config_info) 184 | self.item_selected.connect(self._update_chain_info) 185 | self.item_selected.connect(self._update_record_text) 186 | 187 | def set_filter(text): 188 | text = text.strip().lower() 189 | for idx in range(self.item_list.count()): 190 | item = self.item_list.item(idx) 191 | item.setHidden(bool(text and text not in item.text().lower())) 192 | 193 | self.item_list_filter.textEdited.connect(set_filter) 194 | 195 | self._update_item_list() 196 | 197 | def _item_selected(self, current, previous): 198 | "Slot - new list item selected" 199 | if current is None: 200 | return 201 | 202 | record = current.data(Qt.UserRole) 203 | if isinstance(record, pytmc.record.RecordPackage): 204 | self.item_selected.emit(record) 205 | elif isinstance(record, str): # {chain: record} 206 | chain = record 207 | record = self.chains[chain] 208 | self.item_selected.emit(record) 209 | 210 | def _update_config_info(self, record): 211 | "Slot - update config information when a new record is selected" 212 | chain = record.chain 213 | 214 | self.config_info.clear() 215 | self.item_list_filter.setText("") 216 | self.config_info.setRowCount(len(chain.chain)) 217 | 218 | def add_dict_to_table(row: int, d: dict): 219 | """ 220 | Parameters 221 | ---------- 222 | row : int 223 | Identify the row to configure. 224 | 225 | d : dict 226 | Dictionary of items to enter into the target row. 227 | """ 228 | for key, value in d.items(): 229 | key = str(key) 230 | if key not in columns: 231 | columns[key] = max(columns.values()) + 1 if columns else 0 232 | # accumulate a list of entries to print in the chart. These 233 | # should only be printed after `setColumnCount` has been run 234 | if isinstance(value, dict): 235 | yield from add_dict_to_table(row, value) 236 | else: 237 | yield (row, columns[key], QtWidgets.QTableWidgetItem(str(value))) 238 | 239 | columns = {} 240 | column_write_entries = [] 241 | 242 | items = zip(chain.config["pv"], chain.item_to_config.items()) 243 | for row, (pv, (item, config)) in enumerate(items): 244 | # info_dict is a collection of the non-field pragma lines 245 | info_dict = dict(pv=pv) 246 | if config is not None: 247 | info_dict.update({k: v for k, v in config.items() if k != "field"}) 248 | new_entries = add_dict_to_table(row, info_dict) 249 | column_write_entries.extend(new_entries) 250 | # fields is a dictionary exclusively contining fields 251 | fields = config.get("field", {}) 252 | new_entries = add_dict_to_table( 253 | row, {f"field_{k}": v for k, v in fields.items() if k != "field"} 254 | ) 255 | column_write_entries.extend(new_entries) 256 | 257 | # setColumnCount seems to need to proceed the setHorizontalHeaderLabels 258 | # in order to prevent QT from incorrectly drawing/labeling the cols 259 | self.config_info.setColumnCount(len(columns)) 260 | self.config_info.setHorizontalHeaderLabels(list(columns)) 261 | # finally print the column's entries 262 | for line in column_write_entries: 263 | self.config_info.setItem(*line) 264 | 265 | self.config_info.setVerticalHeaderLabels( 266 | list(item.name for item in chain.item_to_config) 267 | ) 268 | 269 | def _update_record_text(self, record): 270 | "Slot - update record text when a new record is selected" 271 | if self._mode.lower() == "chains w/o records": 272 | # TODO: a more helpful message oculd be useful 273 | self.record_text.setText("NOT GENERATED") 274 | else: 275 | self.record_text.setText(self.records[record]) 276 | 277 | def _update_chain_info(self, record): 278 | "Slot - update chain information when a new record is selected" 279 | self.chain_info.clear() 280 | for chain in record.chain.chain: 281 | self.chain_info.addItem(str(chain)) 282 | 283 | def _update_view_type(self, name): 284 | self._mode = name.lower() 285 | self._update_item_list() 286 | 287 | def _update_item_list(self): 288 | self.item_list.clear() 289 | if self._mode == "chains": 290 | items = self.chains.items() 291 | elif self._mode == "records": 292 | items = [ 293 | (" / ".join(_grep_record_names(db_text)) or "Unknown", record) 294 | for record, db_text in self.records.items() 295 | ] 296 | elif self._mode == "chains w/o records": 297 | items = self.incomplete_chains.items() 298 | else: 299 | return 300 | for name, record in sorted(items, key=lambda item: item[0]): 301 | item = QtWidgets.QListWidgetItem(name) 302 | item.setData(Qt.UserRole, record) 303 | self.item_list.addItem(item) 304 | 305 | 306 | def create_debug_gui(tmc, dbd=None, allow_no_pragma=False): 307 | """ 308 | Show the results of tmc processing in a Qt gui. 309 | 310 | Parameters 311 | ---------- 312 | tmc : TmcFile, str, pathlib.Path 313 | The tmc file to show. 314 | 315 | dbd : DbdFile, optional 316 | The dbd file to lint against. 317 | 318 | allow_no_pragma : bool, optional 319 | Look for chains that have missing pragmas. 320 | """ 321 | 322 | if isinstance(tmc, (str, pathlib.Path)): 323 | tmc = pytmc.parser.parse(tmc) 324 | 325 | if dbd is not None and not isinstance(dbd, pytmc.linter.DbdFile): 326 | dbd = pytmc.linter.DbdFile(dbd) 327 | 328 | return TmcSummary(tmc, dbd, allow_no_pragma=allow_no_pragma) 329 | 330 | 331 | def build_arg_parser(parser=None): 332 | if parser is None: 333 | parser = argparse.ArgumentParser() 334 | 335 | parser.description = DESCRIPTION 336 | parser.formatter_class = argparse.RawTextHelpFormatter 337 | 338 | parser.add_argument("tmc_file", metavar="INPUT", type=str, help="Path to .tmc file") 339 | 340 | parser.add_argument( 341 | "-d", 342 | "--dbd", 343 | default=None, 344 | type=str, 345 | help=( 346 | "Specify an expanded .dbd file for validating fields " "(requires pyPDB)" 347 | ), 348 | ) 349 | 350 | parser.add_argument( 351 | "-a", 352 | "--allow-no-pragma", 353 | action="store_true", 354 | help="Show all items, even those missing pragmas (warning: slow)", 355 | ) 356 | 357 | return parser 358 | 359 | 360 | def main(tmc_file, *, dbd=None, allow_no_pragma=False): 361 | app = QtWidgets.QApplication([]) 362 | interface = create_debug_gui(tmc_file, dbd, allow_no_pragma=allow_no_pragma) 363 | interface.show() 364 | sys.exit(app.exec_()) 365 | -------------------------------------------------------------------------------- /pytmc/bin/iocboot.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc iocboot" is a command line utility for bootstrapping new EPICS IOCs 3 | based on TwinCAT3 .tsproj projects. 4 | """ 5 | 6 | import argparse 7 | import getpass 8 | import logging 9 | import os 10 | import pathlib 11 | 12 | import jinja2 13 | 14 | from ..parser import parse 15 | from . import util 16 | 17 | DESCRIPTION = __doc__ 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def build_arg_parser(parser=None): 22 | if parser is None: 23 | parser = argparse.ArgumentParser() 24 | 25 | parser.description = DESCRIPTION 26 | parser.formatter_class = argparse.RawTextHelpFormatter 27 | 28 | parser.add_argument("tsproj_project", type=str, help="Path to .tsproj project") 29 | 30 | parser.add_argument( 31 | "ioc_template_path", type=str, help="Path to IOC template directory" 32 | ) 33 | 34 | parser.add_argument( 35 | "--prefix", 36 | type=str, 37 | default="ioc-", 38 | help="IOC boot directory prefix [default: ioc-]", 39 | ) 40 | 41 | parser.add_argument( 42 | "--makefile-name", 43 | type=str, 44 | default="Makefile.ioc", 45 | help="Jinja2 template for the IOC Makefile [default: Makefile.ioc]", 46 | ) 47 | 48 | parser.add_argument( 49 | "--overwrite", action="store_true", help="Overwrite existing files" 50 | ) 51 | 52 | parser.add_argument( 53 | "--dry-run", action="store_true", help="Dry-run only - do not write files" 54 | ) 55 | 56 | parser.add_argument( 57 | "--plcs", 58 | type=str, 59 | action="append", 60 | help="Specify one or more PLC names to generate", 61 | ) 62 | 63 | parser.add_argument( 64 | "--debug", 65 | "-d", 66 | action="store_true", 67 | help="Post-stcmd, open an interactive Python session", 68 | ) 69 | 70 | return parser 71 | 72 | 73 | def main( 74 | tsproj_project, 75 | ioc_template_path, 76 | *, 77 | prefix="ioc-", 78 | debug=False, 79 | overwrite=False, 80 | makefile_name="Makefile.ioc", 81 | dry_run=False, 82 | plcs=None, 83 | ): 84 | jinja_env = jinja2.Environment( 85 | loader=jinja2.FileSystemLoader(ioc_template_path), 86 | trim_blocks=True, 87 | lstrip_blocks=True, 88 | ) 89 | 90 | tsproj_project = pathlib.Path(tsproj_project).expanduser().absolute() 91 | project = parse(tsproj_project) 92 | 93 | solution_path = tsproj_project.parent.parent 94 | 95 | ioc_template_path = pathlib.Path(ioc_template_path) 96 | makefile_template_path = ioc_template_path / makefile_name 97 | if not makefile_template_path.exists(): 98 | raise RuntimeError(f"File not found: {makefile_template_path}") 99 | 100 | template = jinja_env.get_template(makefile_name) 101 | 102 | for plc_name, plc in project.plcs_by_name.items(): 103 | if plcs is not None and plc_name not in plcs: 104 | continue 105 | 106 | ioc_name = plc_name.replace("_", "-") 107 | ioc_path = pathlib.Path(f"{prefix}{ioc_name}").absolute() 108 | if not dry_run: 109 | os.makedirs(ioc_path, exist_ok=True) 110 | makefile_path = ioc_path / "Makefile" 111 | 112 | plc_path = pathlib.Path(plc.filename).parent 113 | template_args = dict( 114 | project_name=tsproj_project.stem, 115 | plc_name=plc_name, 116 | tsproj_path=os.path.relpath(tsproj_project, ioc_path), 117 | project_path=os.path.relpath(tsproj_project.parent, ioc_path), 118 | template_path=ioc_template_path, 119 | solution_path=os.path.relpath(solution_path, ioc_path), 120 | plcproj=os.path.relpath(plc.filename, ioc_path), 121 | plc_path=os.path.relpath(plc_path, ioc_path), 122 | plc_ams_id=plc.ams_id, 123 | plc_ip=plc.target_ip, 124 | plc_ads_port=plc.port, 125 | user=getpass.getuser(), 126 | ) 127 | 128 | stashed_exception = None 129 | try: 130 | rendered = template.render(**template_args) 131 | except Exception as ex: 132 | stashed_exception = ex 133 | rendered = None 134 | 135 | if not debug: 136 | if dry_run: 137 | print() 138 | print("---" * 30) 139 | print(makefile_path) 140 | print("---" * 30) 141 | 142 | if stashed_exception is not None: 143 | print( 144 | "Failed:", type(stashed_exception).__name__, stashed_exception 145 | ) 146 | print() 147 | else: 148 | if makefile_path.exists(): 149 | print( 150 | "** OVERWRITING **" 151 | if overwrite 152 | else "** FAIL: already exists **" 153 | ) 154 | print() 155 | print(rendered) 156 | else: 157 | if stashed_exception is not None: 158 | raise stashed_exception 159 | 160 | if makefile_path.exists() and not overwrite: 161 | raise RuntimeError( 162 | "Must specify --overwrite to write over" " existing Makefiles" 163 | ) 164 | with open(makefile_path, "w") as f: 165 | print(rendered, file=f) 166 | 167 | else: 168 | message = ["Variables: project, plc, template "] 169 | if stashed_exception is not None: 170 | message.append( 171 | f"Exception: {type(stashed_exception)} " f"{stashed_exception}" 172 | ) 173 | 174 | util.python_debug_session(namespace=locals(), message="\n".join(message)) 175 | -------------------------------------------------------------------------------- /pytmc/bin/pragmalint.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc pragmalint" is a command line utility for linting PyTMC pragmas in a 3 | given TwinCAT project or source code file 4 | """ 5 | 6 | import argparse 7 | import logging 8 | import pathlib 9 | import re 10 | import sys 11 | import textwrap 12 | import types 13 | 14 | from .. import parser, pragmas 15 | from . import util 16 | from .db import LinterError 17 | 18 | DESCRIPTION = __doc__ 19 | logger = logging.getLogger(__name__) 20 | 21 | PRAGMA_START_RE = re.compile("{attribute") 22 | PRAGMA_RE = re.compile( 23 | r"^{\s*attribute[ \t]+'pytmc'[ \t]*:=[ \t]*'(?P[\s\S]*)'}$", re.MULTILINE 24 | ) 25 | PRAGMA_LINE_RE = re.compile(r"([^\r\n$;]*)", re.MULTILINE) 26 | PRAGMA_SETTING_RE = re.compile(r"\s*(?P[a-zA-Z0-9]+)\s*:\s*(?P<setting>.*?)\s*$") 27 | PRAGMA_PV_LINE_RE = re.compile(r"pv\s*:") 28 | 29 | 30 | def build_arg_parser(argparser=None): 31 | if argparser is None: 32 | argparser = argparse.ArgumentParser() 33 | 34 | argparser.description = DESCRIPTION 35 | argparser.formatter_class = argparse.RawTextHelpFormatter 36 | 37 | argparser.add_argument( 38 | "filename", type=str, help="Path to .tsproj project or source code file" 39 | ) 40 | 41 | argparser.add_argument( 42 | "--markdown", 43 | dest="use_markdown", 44 | action="store_true", 45 | help="Make output more markdown-friendly, for easier sharing", 46 | ) 47 | 48 | argparser.add_argument( 49 | "--verbose", 50 | "-v", 51 | action="store_true", 52 | help="Show all pragmas, including good ones", 53 | ) 54 | 55 | return argparser 56 | 57 | 58 | def match_single_pragma(code): 59 | """ 60 | Given a block of code starting at a pragma, return the pragma up to its 61 | closing curly brace. 62 | """ 63 | curly_count = 0 64 | for i, char in enumerate(code): 65 | if char == "{": 66 | curly_count += 1 67 | elif char == "}": 68 | curly_count -= 1 69 | if i > 0 and curly_count == 0: 70 | return code[: i + 1] 71 | 72 | 73 | def find_pragmas(code): 74 | for match in PRAGMA_START_RE.finditer(code): 75 | yield match.start(0), match_single_pragma(code[match.start(0) :]) 76 | 77 | 78 | def lint_pragma(pragma): 79 | """ 80 | Lint a pragma against the PRAGMA_RE regular expression. Raises LinterError 81 | """ 82 | if pragma is None or "pytmc" not in pragma: 83 | return 84 | 85 | pragma = pragma.strip() 86 | match = PRAGMA_RE.match(pragma) 87 | if not match: 88 | raise LinterError() 89 | 90 | try: 91 | pragma_setting = match.groupdict()["setting"] 92 | except KeyError: 93 | # if no configuration region in the pragma was detected then fail 94 | raise LinterError() 95 | 96 | if "$" in pragma_setting: 97 | # Why, Beckhoff, why? (as of 4022.30, at least) 98 | raise LinterError( 99 | 'Pragma cannot contain "$" or TwinCAT will ignore it. The ' 100 | 'character "@" can be used as a work-around (or specify ' 101 | "`macro_character` in your pragma configuration)" 102 | ) 103 | 104 | config_lines = PRAGMA_LINE_RE.findall(pragma_setting) 105 | if len(config_lines) == 0: 106 | raise LinterError( 107 | """It is not acceptable to lack configuration lines. 108 | At a minimum "pv: " must exist""" 109 | ) 110 | 111 | config_lines_detected = 0 112 | pv_line_detected = 0 113 | 114 | for line in config_lines: 115 | line_match = PRAGMA_SETTING_RE.match(line) 116 | if line_match: 117 | config_lines_detected += 1 118 | pv_match = PRAGMA_PV_LINE_RE.search(line) 119 | if pv_match: 120 | pv_line_detected += 1 121 | 122 | # There shall be at one config line at minimum 123 | if config_lines_detected <= 0: 124 | raise LinterError("No configuration line(s) detected in a pragma.") 125 | 126 | # There shall be a config line for pv even if it's just "pv:" 127 | if pv_line_detected <= 0: 128 | raise LinterError("No pv line(s) detected in a pragma") 129 | 130 | for pvname, configs in pragmas.separate_configs_by_pv( 131 | pragmas.split_pytmc_pragma(pragma_setting) 132 | ): 133 | if " " in pvname: 134 | raise LinterError( 135 | f"Space found in PV name: {pvname!r} (missing delimiter?)" 136 | ) 137 | 138 | config = pragmas.dictify_config(configs) 139 | 140 | if "io" in config: 141 | io = config["io"] 142 | try: 143 | pragmas.normalize_io(io) 144 | except ValueError: 145 | raise LinterError(f"Invalid i/o direction for {pvname}: {io}") from None 146 | 147 | if "update" in config: 148 | update = config["update"] 149 | try: 150 | pragmas.parse_update_rate(update) 151 | except ValueError as ex: 152 | raise LinterError(f"Invalid update rate for {pvname}: {ex}") from None 153 | 154 | if "archive" in config: 155 | archive = config["archive"] 156 | try: 157 | pragmas.parse_archive_settings(archive) 158 | except ValueError as ex: 159 | raise LinterError( 160 | f"Invalid archive settings for {pvname}: {ex}" 161 | ) from None 162 | 163 | if "link" in config: 164 | link = config["link"] 165 | if " " in link: 166 | raise LinterError( 167 | f"Invalid link settings for {pvname}: spaces in pvname" 168 | ) 169 | if pragmas.normalize_io(config.get("io", "io")) != "output": 170 | raise LinterError( 171 | f"Invalid link settings for {pvname}: " f"read-write I/O required" 172 | ) 173 | 174 | fields = config.get("field", {}) 175 | if fields.get("SCAN", "I/O Intr") != "I/O Intr": 176 | raise LinterError( 177 | f"SCAN field cannot be customized ({pvname}); " 178 | f"use `update` pragma key" 179 | ) 180 | 181 | return match 182 | 183 | 184 | def _build_map_of_offset_to_line_number(source): 185 | """ 186 | For a multiline source file, return {character_pos: line} 187 | """ 188 | start_index = 0 189 | index_to_line_number = {} 190 | # A slow and bad algorithm, but only to be used in parsing declarations 191 | # which are rather small 192 | for line_number, line in enumerate(source.splitlines(), 1): 193 | for index in range(start_index, start_index + len(line) + 1): 194 | index_to_line_number[index] = line_number 195 | start_index += len(line) + 1 196 | return index_to_line_number 197 | 198 | 199 | def lint_source(filename, source, verbose=False): 200 | """ 201 | Lint `filename` given TwincatItem `source`. 202 | 203 | Parameters 204 | ---------- 205 | filename : str 206 | Target file name to be linted. 207 | 208 | source : subclass of pytmc.parser.TwincatItem 209 | Representation of TwinCAT project chunk in which to search for the 210 | filename argument 211 | 212 | verbose : bool 213 | Show more context around the linting process, including all source file 214 | names and number of pragmas found 215 | """ 216 | heading_shown = False 217 | for decl in source.find(parser.Declaration): 218 | if not (decl.text or "").strip(): 219 | continue 220 | 221 | offset_to_line_number = _build_map_of_offset_to_line_number(decl.text) 222 | 223 | parent = decl.parent 224 | path_to_source = [] 225 | while parent is not source: 226 | if parent.name is not None: 227 | path_to_source.insert(0, parent.name) 228 | parent = parent.parent 229 | pragmas = list(find_pragmas(decl.text)) 230 | if not pragmas: 231 | continue 232 | 233 | if verbose: 234 | if not heading_shown: 235 | print() 236 | util.sub_heading(f"{filename} ({source.tag})") 237 | heading_shown = True 238 | 239 | util.sub_sub_heading( 240 | f'{".".join(path_to_source)}: {decl.tag} - ' f"{len(pragmas)} pragmas" 241 | ) 242 | 243 | for offset, pragma in pragmas: 244 | info = dict( 245 | pragma=pragma, 246 | filename=filename, 247 | tag=source.tag, 248 | line_number=offset_to_line_number.get(offset), 249 | exception=None, 250 | ) 251 | 252 | try: 253 | lint_pragma(pragma) 254 | except LinterError as ex: 255 | info["exception"] = ex 256 | except Exception as ex: 257 | wrapped_ex = LinterError(f"Unhandled exception: {ex}") 258 | wrapped_ex.original_ex = ex 259 | info["exception"] = wrapped_ex 260 | 261 | yield types.SimpleNamespace(**info) 262 | 263 | 264 | def main(filename, use_markdown=False, verbose=False): 265 | proj_path = pathlib.Path(filename) 266 | project = parser.parse(proj_path) 267 | pragma_count = 0 268 | linter_errors = 0 269 | 270 | if hasattr(project, "plcs"): 271 | for i, plc in enumerate(project.plcs, 1): 272 | util.heading(f"PLC Project ({i}): {plc.project_path.stem}") 273 | for fn, source in plc.source.items(): 274 | for info in lint_source(fn, source, verbose=verbose): 275 | pragma_count += 1 276 | if info.exception is not None: 277 | linter_errors += 1 278 | logger.error( 279 | "Linter error: %s\n%s:line %s: %s", 280 | info.exception, 281 | info.filename, 282 | info.line_number, 283 | textwrap.indent(info.pragma or "", " "), 284 | ) 285 | if hasattr(info.exception, "original_ex"): 286 | logger.error( 287 | "Unhandled exception (may be a pytmc bug)", 288 | exc_info=info.exception.original_ex, 289 | ) 290 | else: 291 | source = project 292 | for info in lint_source(filename, source, verbose=verbose): 293 | pragma_count += 1 294 | if info.exception is not None: 295 | linter_errors += 1 296 | logger.error( 297 | "Linter error: %s\n%s:line %s: %s", 298 | info.exception, 299 | info.filename, 300 | info.line_number, 301 | textwrap.indent(info.pragma or "", " "), 302 | ) 303 | if hasattr(info.exception, "original_ex"): 304 | logger.error( 305 | "Unhandled exception (may be a pytmc bug)", 306 | exc_info=info.exception.original_ex, 307 | ) 308 | 309 | logger.info( 310 | "Total pragmas found: %d Total linter errors: %d", pragma_count, linter_errors 311 | ) 312 | 313 | if linter_errors > 0: 314 | sys.exit(1) 315 | -------------------------------------------------------------------------------- /pytmc/bin/pytmc.py: -------------------------------------------------------------------------------- 1 | """ 2 | `pytmc` is the top-level command for accessing various subcommands. 3 | 4 | Try:: 5 | 6 | """ 7 | 8 | import argparse 9 | import importlib 10 | import logging 11 | 12 | import pytmc 13 | 14 | DESCRIPTION = __doc__ 15 | 16 | 17 | MODULES = ( 18 | "summary", 19 | "stcmd", 20 | "db", 21 | "xmltranslate", 22 | "debug", 23 | "types", 24 | "iocboot", 25 | "pragmalint", 26 | "code", 27 | "template", 28 | ) 29 | 30 | 31 | def _try_import(module): 32 | relative_module = f".{module}" 33 | return importlib.import_module(relative_module, "pytmc.bin") 34 | 35 | 36 | def _build_commands(): 37 | global DESCRIPTION 38 | result = {} 39 | unavailable = [] 40 | 41 | for module in sorted(MODULES): 42 | try: 43 | mod = _try_import(module) 44 | except Exception as ex: 45 | unavailable.append((module, ex)) 46 | else: 47 | result[module] = (mod.build_arg_parser, mod.main) 48 | DESCRIPTION += f"\n $ pytmc {module} --help" 49 | 50 | if unavailable: 51 | DESCRIPTION += "\n\n" 52 | 53 | for module, ex in unavailable: 54 | DESCRIPTION += ( 55 | f"WARNING: pytmc {module!r} is unavailable due to:" 56 | f"\n\t{ex.__class__.__name__}: {ex}" 57 | ) 58 | 59 | return result 60 | 61 | 62 | COMMANDS = _build_commands() 63 | 64 | 65 | def main(): 66 | top_parser = argparse.ArgumentParser( 67 | prog="pytmc", 68 | description=DESCRIPTION, 69 | formatter_class=argparse.RawTextHelpFormatter, 70 | ) 71 | 72 | top_parser.add_argument( 73 | "--version", 74 | "-V", 75 | action="version", 76 | version=pytmc.__version__, 77 | help="Show the pytmc version number and exit.", 78 | ) 79 | 80 | top_parser.add_argument( 81 | "--log", 82 | "-l", 83 | dest="log_level", 84 | default="INFO", 85 | type=str, 86 | help="Python logging level (e.g. DEBUG, INFO, WARNING)", 87 | ) 88 | 89 | subparsers = top_parser.add_subparsers(help="Possible subcommands") 90 | for command_name, (build_func, main) in COMMANDS.items(): 91 | sub = subparsers.add_parser(command_name) 92 | build_func(sub) 93 | sub.set_defaults(func=main) 94 | 95 | args = top_parser.parse_args() 96 | kwargs = vars(args) 97 | log_level = kwargs.pop("log_level") 98 | 99 | logger = logging.getLogger("pytmc") 100 | logger.setLevel(log_level) 101 | logging.basicConfig() 102 | 103 | if hasattr(args, "func"): 104 | func = kwargs.pop("func") 105 | logger.debug("%s(**%r)", func.__name__, kwargs) 106 | func(**kwargs) 107 | else: 108 | top_parser.print_help() 109 | 110 | 111 | if __name__ == "__main__": 112 | main() 113 | -------------------------------------------------------------------------------- /pytmc/bin/stcmd.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc-stcmd" is a command line utility for generating ESS/ethercatmc-capable 3 | EPICS startup st.cmd files directly from TwinCAT3 .tsproj projects. 4 | 5 | Relies on the existence (and linking) of FB_MotionStage function blocks. 6 | """ 7 | 8 | import argparse 9 | import getpass 10 | import logging 11 | import pathlib 12 | 13 | import jinja2 14 | 15 | from .. import pragmas, record 16 | from ..parser import NC, Symbol, parse, separate_by_classname 17 | from . import db, util 18 | 19 | DESCRIPTION = __doc__ 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def build_arg_parser(parser=None): 24 | if parser is None: 25 | parser = argparse.ArgumentParser() 26 | 27 | parser.description = DESCRIPTION 28 | parser.formatter_class = argparse.RawTextHelpFormatter 29 | 30 | parser.add_argument("tsproj_project", type=str, help="Path to .tsproj project") 31 | 32 | parser.add_argument( 33 | "--plc", 34 | type=str, 35 | default=None, 36 | dest="plc_name", 37 | help="PLC project name, if multiple exist", 38 | ) 39 | 40 | parser.add_argument( 41 | "-p", "--prefix", type=str, default=None, help="PV prefix for the IOC" 42 | ) 43 | 44 | parser.add_argument( 45 | "--hashbang", 46 | type=str, 47 | default="../../bin/rhel7-x86_64/adsIoc", 48 | help="Indicates to the shell which binary to use for the st.cmd script", 49 | ) 50 | 51 | parser.add_argument( 52 | "--binary", 53 | type=str, 54 | dest="binary_name", 55 | default="adsIoc", 56 | help="IOC application binary name", 57 | ) 58 | 59 | parser.add_argument( 60 | "-n", 61 | "--name", 62 | type=str, 63 | default=None, 64 | help="IOC name (defaults to project name)", 65 | ) 66 | 67 | parser.add_argument( 68 | "--only-motor", 69 | action="store_true", 70 | help=( 71 | "Parse the project only for motor records, skipping other " 72 | "variables with pytmc pragmas" 73 | ), 74 | ) 75 | 76 | parser.add_argument("--db-path", type=str, default=".", help="Path for db files") 77 | 78 | parser.add_argument( 79 | "--dbd", type=str, default=None, help="Path to the IOC dbd file" 80 | ) 81 | 82 | parser.add_argument("--delim", type=str, default=":", help="Preferred PV delimiter") 83 | 84 | parser.add_argument( 85 | "--debug", 86 | "-d", 87 | action="store_true", 88 | help="Post-stcmd, open an interactive Python session", 89 | ) 90 | 91 | parser.add_argument( 92 | "--template", 93 | type=str, 94 | dest="template_filename", 95 | default="stcmd_default.cmd", 96 | help="st.cmd Jinja2 template", 97 | ) 98 | 99 | parser.add_argument( 100 | "--template-path", 101 | type=str, 102 | default=".", 103 | help="Location where templates are stored", 104 | ) 105 | 106 | parser.add_argument( 107 | "--allow-errors", 108 | action="store_true", 109 | help="Allow non-fatal errors to be ignored", 110 | ) 111 | 112 | return parser 113 | 114 | 115 | def get_name(obj, user_config): 116 | """ 117 | Get an EPICS prefix and record name for a given TwincatItem 118 | 119 | This function only looks at a single TwincatItem, not from an entire chain 120 | of items. It allows certain special symbols (e.g., ST_MotionStage) to 121 | optionally not have a PV pragma, as it can default to the NC axis name 122 | instead. 123 | 124 | Parameters 125 | ---------- 126 | obj : TwincatItem 127 | The item to get an EPICS name for 128 | user_config : dict 129 | Configuration passed in from the user command-line. Required keys are 130 | {'prefix', 'delim'}. 131 | 132 | Returns 133 | ------- 134 | (prefix, name) : (str, str) 135 | Combined, {prefix}{name} gives the full record name in EPICS. 136 | """ 137 | # First check if there is a pytmc pragma 138 | item_and_config = pragmas.expand_configurations_from_chain([obj]) 139 | delim = user_config["delim"] 140 | prefix = user_config["prefix"] 141 | if item_and_config: 142 | item_to_config = dict(item_and_config[0]) 143 | if item_to_config: 144 | chain = pragmas.SingularChain(item_to_config=item_to_config) 145 | # PV name specified in the pragma - use it as-is 146 | if chain.config.get("pv"): 147 | record_package = record.RecordPackage( 148 | ads_port='', 149 | chain=chain, 150 | ) 151 | if delim in record_package.pvname: 152 | pv_parts = record_package.pvname.split(delim) 153 | # Break the PV parts into a prefix and suffix, using all but 154 | # the last section as the prefix. 155 | prefix = delim.join(pv_parts[:-1]) + delim 156 | suffix = pv_parts[-1] 157 | return prefix, suffix 158 | return "", record_package.pvname 159 | 160 | if hasattr(obj, "nc_axis"): 161 | nc_axis = obj.nc_axis 162 | # Fall back to using the NC axis name, replacing underscores/spaces 163 | # with the user-specified delimiter 164 | name = nc_axis.name.replace(" ", delim) 165 | return prefix + delim, name.replace("_", delim) 166 | return "", obj.name 167 | 168 | 169 | def jinja_filters(**user_config): 170 | "All jinja filters" 171 | # TODO all can be cached based on object, if necessary 172 | 173 | def epics_prefix(obj): 174 | return get_name(obj, user_config)[0] 175 | 176 | def epics_suffix(obj): 177 | return get_name(obj, user_config)[1] 178 | 179 | def pragma(obj, key, default=""): 180 | item_and_config = pragmas.expand_configurations_from_chain([obj]) 181 | if item_and_config: 182 | item_to_config = dict(item_and_config[0]) 183 | config = pragmas.squash_configs(*item_to_config.values()) 184 | return config.get(key, default) 185 | return default 186 | 187 | return {k: v for k, v in locals().items() if not k.startswith("_")} 188 | 189 | 190 | def main( 191 | tsproj_project, 192 | *, 193 | name=None, 194 | prefix=None, 195 | template_filename="stcmd_default.cmd", 196 | plc_name=None, 197 | dbd=None, 198 | db_path=".", 199 | only_motor=False, 200 | binary_name="ads", 201 | delim=":", 202 | template_path=".", 203 | debug=False, 204 | allow_errors=False, 205 | hashbang="../../bin/rhel7-x86_64/adsIoc", 206 | ): 207 | jinja_loader = jinja2.ChoiceLoader( 208 | [ 209 | jinja2.PackageLoader("pytmc", "templates"), 210 | jinja2.FileSystemLoader(template_path), 211 | ] 212 | ) 213 | jinja_env = jinja2.Environment( 214 | loader=jinja_loader, 215 | trim_blocks=True, 216 | lstrip_blocks=True, 217 | ) 218 | 219 | if not name: 220 | name = pathlib.Path(tsproj_project).stem 221 | 222 | if not prefix: 223 | prefix = name.upper() 224 | 225 | jinja_env.filters.update(**jinja_filters(delim=delim, prefix=prefix, name=name)) 226 | 227 | template = jinja_env.get_template(template_filename) 228 | 229 | project = parse(tsproj_project) 230 | 231 | additional_db_files = [] 232 | try: 233 | (plc,) = project.plcs 234 | except ValueError: 235 | by_name = project.plcs_by_name 236 | if not plc_name: 237 | raise RuntimeError( 238 | f"Only single PLC projects supported. " 239 | f"Use --plc and choose from {list(by_name)}" 240 | ) 241 | 242 | try: 243 | plc = project.plcs_by_name[plc_name] 244 | except KeyError: 245 | raise RuntimeError( 246 | f"PLC project {plc_name!r} not found. " f"Projects: {list(by_name)}" 247 | ) 248 | 249 | symbols = separate_by_classname(plc.find(Symbol)) 250 | motors = [ 251 | mot for mot in symbols.get("Symbol_ST_MotionStage", []) if not mot.is_pointer 252 | ] 253 | 254 | if plc.tmc is None: 255 | logger.warning("No TMC file found; no records to be generated") 256 | elif not only_motor: 257 | other_records, _ = db.process(plc.tmc, dbd_file=dbd, allow_errors=allow_errors) 258 | if not other_records: 259 | logger.info( 260 | "No additional records from pytmc found in %s", plc.tmc.filename 261 | ) 262 | else: 263 | db_filename = f"{plc.filename.stem}.db" 264 | db_path = pathlib.Path(db_path) / db_filename 265 | logger.info( 266 | "Found %d additional records; writing to %s", 267 | len(other_records), 268 | db_path, 269 | ) 270 | with open(db_path, "w") as db_file: 271 | db_file.write("\n\n".join(rec.render() for rec in other_records)) 272 | additional_db_files.append({"file": db_filename, "macros": ""}) 273 | 274 | ams_id = plc.ams_id 275 | target_ip = plc.target_ip 276 | if not allow_errors: 277 | if not ams_id: 278 | raise RuntimeError( 279 | "AMS ID unset. Try --allow-errors if this is " "not an issue." 280 | ) 281 | if not target_ip: 282 | raise RuntimeError( 283 | "IP address unset. Try --allow-errors if this " "is not an issue." 284 | ) 285 | 286 | try: 287 | (nc,) = list(project.find(NC, recurse=False)) 288 | except Exception: 289 | nc = None 290 | 291 | template_args = dict( 292 | hashbang=hashbang, 293 | binary_name=binary_name, 294 | name=name, 295 | prefix=prefix, 296 | delim=delim, 297 | user=getpass.getuser(), 298 | motor_port="PLC_ADS", 299 | asyn_port="ASYN_PLC", 300 | plc_ams_id=ams_id, 301 | plc_ip=target_ip, 302 | plc_ads_port=plc.port, 303 | additional_db_files=additional_db_files, 304 | symbols=symbols, 305 | motors=motors, 306 | nc=nc, 307 | ) 308 | 309 | stashed_exception = None 310 | try: 311 | rendered = template.render(**template_args) 312 | except Exception as ex: 313 | stashed_exception = ex 314 | rendered = None 315 | 316 | if not debug: 317 | if stashed_exception is not None: 318 | raise stashed_exception 319 | print(rendered) 320 | else: 321 | message = ["Variables: project, symbols, plc, template. "] 322 | if stashed_exception is not None: 323 | message.append( 324 | f"Exception: {type(stashed_exception)} " f"{stashed_exception}" 325 | ) 326 | 327 | util.python_debug_session(namespace=locals(), message="\n".join(message)) 328 | -------------------------------------------------------------------------------- /pytmc/bin/summary.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc-summary" is a command line utility for inspecting TwinCAT3 3 | .tsproj projects. 4 | """ 5 | 6 | import argparse 7 | import ast 8 | import fnmatch 9 | import pathlib 10 | import sys 11 | 12 | from .. import parser, pragmas 13 | from . import util 14 | 15 | DESCRIPTION = __doc__ 16 | 17 | 18 | def build_arg_parser(argparser=None): 19 | if argparser is None: 20 | argparser = argparse.ArgumentParser() 21 | 22 | argparser.description = DESCRIPTION 23 | argparser.formatter_class = argparse.RawTextHelpFormatter 24 | 25 | argparser.add_argument( 26 | "filename", type=str, help="Path to project or solution (.tsproj, .sln)" 27 | ) 28 | 29 | argparser.add_argument( 30 | "--all", 31 | "-a", 32 | dest="show_all", 33 | action="store_true", 34 | help="All possible information", 35 | ) 36 | 37 | argparser.add_argument( 38 | "--outline", dest="show_outline", action="store_true", help="Outline XML" 39 | ) 40 | 41 | argparser.add_argument( 42 | "--boxes", "-b", dest="show_boxes", action="store_true", help="Show boxes" 43 | ) 44 | 45 | argparser.add_argument( 46 | "--code", "-c", dest="show_code", action="store_true", help="Show code" 47 | ) 48 | 49 | argparser.add_argument( 50 | "--plcs", "-p", dest="show_plcs", action="store_true", help="Show plcs" 51 | ) 52 | 53 | argparser.add_argument( 54 | "--nc", "-n", dest="show_nc", action="store_true", help="Show NC axes" 55 | ) 56 | 57 | argparser.add_argument( 58 | "--symbols", "-s", dest="show_symbols", action="store_true", help="Show symbols" 59 | ) 60 | 61 | argparser.add_argument( 62 | "--types", 63 | dest="show_types", 64 | action="store_true", 65 | help="Show TMC types and record suffixes, if available", 66 | ) 67 | 68 | argparser.add_argument( 69 | "--filter-types", 70 | action="append", 71 | type=str, 72 | help="Filter the types shown by name", 73 | ) 74 | 75 | argparser.add_argument( 76 | "--links", "-l", dest="show_links", action="store_true", help="Show links" 77 | ) 78 | 79 | argparser.add_argument( 80 | "--markdown", 81 | dest="use_markdown", 82 | action="store_true", 83 | help="Make output more markdown-friendly, for easier sharing", 84 | ) 85 | 86 | argparser.add_argument( 87 | "--debug", 88 | "-d", 89 | action="store_true", 90 | help="Post-summary, open an interactive Python session", 91 | ) 92 | 93 | return argparser 94 | 95 | 96 | def outline(item, *, depth=0, f=sys.stdout): 97 | indent = " " * depth 98 | num_children = len(item._children) 99 | has_container = "C" if hasattr(item, "container") else " " 100 | flags = "".join((has_container,)) 101 | name = item.name or "" 102 | print( 103 | f"{flags}{indent}{item.__class__.__name__} {name} " f"[{num_children}]", file=f 104 | ) 105 | for child in item._children: 106 | outline(child, depth=depth + 1, f=f) 107 | 108 | 109 | def data_type_to_record_info(data_type, pragma): 110 | """ 111 | Get record information from a given TMC DataType. 112 | 113 | Parameters 114 | ---------- 115 | data_type : pytmc.parser.DataType 116 | The data type. 117 | 118 | pragma : str, optional 119 | The pragma to use for a "fake" symbol, in order to generate a full 120 | pytmc chain. This can be used to customize the PV prefix, for example. 121 | 122 | Returns 123 | ------- 124 | info : dict 125 | With keys {'data_type', 'qualified_type_name', 'packages', 'records', 126 | 'errors'}. 127 | """ 128 | symbol = pragmas.make_fake_symbol_from_data_type(data_type, pragma) 129 | packages = [] 130 | records = [] 131 | errors = [] 132 | 133 | for item in pragmas.record_packages_from_symbol(symbol, yield_exceptions=True): 134 | if isinstance(item, Exception): 135 | errors.append(f"{data_type.name} failed: {item}") 136 | continue 137 | 138 | packages.append(item) 139 | try: 140 | records.extend(item.records) 141 | except Exception as ex: 142 | errors.append(f"Package failed: {item.tcname} {ex.__class__.__name__} {ex}") 143 | 144 | return { 145 | "data_type": data_type, 146 | "qualified_type_name": data_type.qualified_type, 147 | "packages": packages, 148 | "records": records, 149 | "errors": errors, 150 | } 151 | 152 | 153 | def enumerate_types(container, pragma="pv: @(PREFIX)", filter_types=None): 154 | """ 155 | Enumerate data types from a parsed TMC file or tsproj. 156 | 157 | Parameters 158 | ---------- 159 | container : pytmc.parser.TcModuleClass 160 | The TMC instance or tsproj instance. 161 | 162 | pragma : str, optional 163 | The pragma to use for a "fake" symbol, in order to generate a full 164 | pytmc chain. This can be used to customize the PV prefix, for example. 165 | 166 | filter_types : list, optional 167 | List of glob-style patterns to match against the types. 168 | 169 | Yields 170 | ------ 171 | info : dict 172 | With keys {'data_type', 'qualified_type_name', 'packages', 'records', 173 | 'errors'}. 174 | """ 175 | for data_type in sorted( 176 | container.find(parser.DataType), key=lambda dt: dt.qualified_type 177 | ): 178 | base_type = data_type.base_type 179 | if base_type and not base_type.is_complex_type: 180 | continue 181 | 182 | if filter_types: 183 | if not any( 184 | fnmatch.fnmatch(data_type.name, filter_type) 185 | for filter_type in filter_types 186 | ): 187 | continue 188 | 189 | yield data_type_to_record_info(data_type, pragma) 190 | 191 | 192 | def list_types(container, pragma="pv: @(PREFIX)", filter_types=None, file=sys.stdout): 193 | for item in enumerate_types(container, pragma=pragma, filter_types=filter_types): 194 | output_block = [ 195 | record.pvname 196 | for record in sorted(item["records"], key=lambda record: record.pvname) 197 | ] 198 | if not item["records"] and filter_types: 199 | output_block.append("** Matched user filter (but no records)") 200 | 201 | output_block.extend([f"!! {error}" for error in item["errors"]]) 202 | 203 | if output_block: 204 | util.sub_heading(f'Data type {item["qualified_type_name"]}', file=file) 205 | util.text_block("\n".join(output_block), file=file) 206 | print(file=file) 207 | 208 | 209 | def summary( 210 | tsproj_project, 211 | use_markdown=False, 212 | show_all=False, 213 | show_outline=False, 214 | show_boxes=False, 215 | show_code=False, 216 | show_plcs=False, 217 | show_nc=False, 218 | show_symbols=False, 219 | show_links=False, 220 | show_types=False, 221 | filter_types=None, 222 | log_level=None, 223 | debug=False, 224 | ): 225 | if not any( 226 | ( 227 | show_all, 228 | show_outline, 229 | show_boxes, 230 | show_code, 231 | show_plcs, 232 | show_nc, 233 | show_symbols, 234 | show_links, 235 | show_types, 236 | debug, 237 | ) 238 | ): 239 | # Show _something_ 240 | show_plcs = True 241 | 242 | proj_path = pathlib.Path(tsproj_project) 243 | proj_root = proj_path.parent.resolve().absolute() 244 | 245 | if proj_path.suffix.lower() not in (".tsproj",): 246 | raise ValueError("Expected a .tsproj file") 247 | 248 | project = parser.parse(proj_path) 249 | 250 | if show_plcs or show_all or show_code or show_symbols or show_types: 251 | for i, plc in enumerate(project.plcs, 1): 252 | util.heading(f"PLC Project ({i}): {plc.project_path.stem}") 253 | print(f" Project root: {proj_root}") 254 | print( 255 | " Project path:", plc.project_path.resolve().relative_to(proj_root) 256 | ) 257 | print(" TMC path: ", plc.tmc_path.resolve().relative_to(proj_root)) 258 | print(f" AMS ID: {plc.ams_id}") 259 | print(f" IP Address: {plc.target_ip} (* based on AMS ID)") 260 | print(f" Port: {plc.port}") 261 | print() 262 | proj_info = [ 263 | ( 264 | "Source files", 265 | [ 266 | pathlib.Path(fn).relative_to(proj_root) 267 | for fn in plc.source_filenames 268 | ], 269 | ), 270 | ("POUs", plc.pou_by_name), 271 | ("GVLs", plc.gvl_by_name), 272 | ] 273 | 274 | for category, items in proj_info: 275 | if items: 276 | print(f" {category}:") 277 | for j, text in enumerate(items, 1): 278 | print(f" {j}.) {text}") 279 | print() 280 | 281 | if show_code: 282 | source_items = ( 283 | list(plc.dut_by_name.items()) 284 | + list(plc.gvl_by_name.items()) 285 | + list(plc.pou_by_name.items()) 286 | ) 287 | for name, source in source_items: 288 | util.sub_heading(f"{source.tag}: {name}") 289 | 290 | fn = source.filename.resolve().relative_to(proj_root) 291 | print(f"File: {fn}") 292 | print() 293 | 294 | if not hasattr(source, "get_source_code"): 295 | continue 296 | 297 | source_text = source.get_source_code() or "" 298 | if source_text.strip(): 299 | util.text_block( 300 | source_text, 301 | markdown_language="vhdl" if use_markdown else None, 302 | ) 303 | print() 304 | 305 | if show_symbols or show_all: 306 | util.sub_heading("Symbols") 307 | symbols = list(plc.find(parser.Symbol)) 308 | for symbol in sorted(symbols, key=lambda symbol: symbol.name): 309 | info = symbol.info 310 | print( 311 | " {name} : {summary_type_name} ({bit_offs} " 312 | "{bit_size})".format(**info) 313 | ) 314 | print() 315 | 316 | if show_types: 317 | if plc.tmc is not None: 318 | util.sub_heading("TMC-defined data types") 319 | list_types(plc.tmc, filter_types=filter_types) 320 | 321 | if show_types: 322 | data_types = getattr(project, "DataTypes", [None])[0] 323 | if data_types is not None: 324 | util.sub_heading("Project-defined data types") 325 | list_types(data_types, filter_types=filter_types) 326 | 327 | if show_boxes or show_all: 328 | util.sub_heading("Boxes") 329 | boxes = list(project.find(parser.Box)) 330 | for box in sorted(boxes, key=lambda box: int(box.attributes["Id"])): 331 | print(f' {box.attributes["Id"]}.) {box.name}') 332 | 333 | if show_nc or show_all: 334 | util.sub_heading("NC axes") 335 | ncs = list(project.find(parser.NC)) 336 | for nc in ncs: 337 | for axis_id, axis in sorted(nc.axis_by_id.items()): 338 | print(f" {axis_id}.) {axis.name!r}:") 339 | for category, info in axis.summarize(): 340 | try: 341 | info = ast.literal_eval(info) 342 | except Exception: 343 | ... 344 | print(f" {category} = {info!r}") 345 | print() 346 | 347 | if show_links or show_all: 348 | util.sub_heading("Links") 349 | links = list(project.find(parser.Link)) 350 | for i, link in enumerate(links, 1): 351 | print(f" {i}.) A {link.a}") 352 | print(f" B {link.b}") 353 | print() 354 | 355 | if show_outline: 356 | outline(project) 357 | 358 | if debug: 359 | util.python_debug_session( 360 | namespace=locals(), 361 | message=( 362 | "The top-level project is accessible as `project`, and " 363 | "TWINCAT_TYPES are in the IPython namespace as well." 364 | ), 365 | ) 366 | 367 | return project 368 | 369 | 370 | def main( 371 | filename, 372 | use_markdown=False, 373 | show_all=False, 374 | show_outline=False, 375 | show_boxes=False, 376 | show_code=False, 377 | show_plcs=False, 378 | show_nc=False, 379 | show_symbols=False, 380 | show_links=False, 381 | show_types=False, 382 | filter_types=None, 383 | log_level=None, 384 | debug=False, 385 | ): 386 | """ 387 | Output a summary of the project or projects provided. 388 | """ 389 | 390 | path = pathlib.Path(filename) 391 | if path.suffix.lower() in (".tsproj",): 392 | project_fns = [path] 393 | elif path.suffix.lower() in (".sln",): 394 | project_fns = parser.projects_from_solution(path) 395 | else: 396 | raise ValueError(f"Expected a tsproj or sln file, got: {path.suffix}") 397 | 398 | projects = [] 399 | for fn in project_fns: 400 | project = summary( 401 | fn, 402 | use_markdown=use_markdown, 403 | show_all=show_all, 404 | show_outline=show_outline, 405 | show_boxes=show_boxes, 406 | show_code=show_code, 407 | show_plcs=show_plcs, 408 | show_nc=show_nc, 409 | show_symbols=show_symbols, 410 | show_links=show_links, 411 | show_types=show_types, 412 | filter_types=filter_types, 413 | log_level=log_level, 414 | debug=debug, 415 | ) 416 | projects.append(project) 417 | 418 | return projects 419 | -------------------------------------------------------------------------------- /pytmc/bin/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | "pytmc-types" is a Qt interface that shows DataType-related information from a 3 | tmc file. 4 | """ 5 | 6 | import argparse 7 | import pathlib 8 | import sys 9 | 10 | from qtpy import QtWidgets 11 | from qtpy.QtCore import Qt, Signal 12 | 13 | import pytmc 14 | 15 | DESCRIPTION = __doc__ 16 | 17 | 18 | def build_arg_parser(parser=None): 19 | if parser is None: 20 | parser = argparse.ArgumentParser() 21 | 22 | parser.description = DESCRIPTION 23 | parser.formatter_class = argparse.RawTextHelpFormatter 24 | 25 | parser.add_argument("tmc_file", metavar="INPUT", type=str, help="Path to .tmc file") 26 | 27 | return parser 28 | 29 | 30 | def find_data_types(tmc): 31 | yield from tmc.find(pytmc.parser.DataType, recurse=False) 32 | 33 | 34 | class TmcTypes(QtWidgets.QMainWindow): 35 | """ 36 | pytmc debug interface 37 | 38 | Parameters 39 | ---------- 40 | tmc : TmcFile 41 | The tmc file to inspect 42 | """ 43 | 44 | item_selected = Signal(object) 45 | 46 | def __init__(self, tmc): 47 | super().__init__() 48 | self.setWindowTitle(str(tmc.filename)) 49 | 50 | # Right part of the window 51 | self.lists = [] 52 | self.types = {} 53 | 54 | self.main_frame = QtWidgets.QFrame() 55 | self.setCentralWidget(self.main_frame) 56 | 57 | self.layout = QtWidgets.QHBoxLayout() 58 | self.main_frame.setLayout(self.layout) 59 | 60 | self.item_list = QtWidgets.QListWidget() 61 | self.layout.addWidget(self.item_list) 62 | 63 | self.item_list.currentItemChanged.connect(self._data_type_selected) 64 | 65 | for dtype in sorted(find_data_types(tmc), key=lambda item: item.name): 66 | item = QtWidgets.QListWidgetItem(dtype.name) 67 | item.setData(Qt.UserRole, dtype) 68 | self.item_list.addItem(item) 69 | 70 | def _data_type_selected(self, current, previous): 71 | "Slot - new list item selected" 72 | if current is None: 73 | return 74 | 75 | dtype = current.data(Qt.UserRole) 76 | self._set_list_count(1) 77 | (list_widget,) = self.lists 78 | self.types[0] = dtype 79 | self._update_list_by_index(0) 80 | 81 | def _set_list_count(self, count): 82 | while len(self.lists) > count: 83 | list_widget = self.lists.pop(-1) 84 | list_widget.clear() 85 | list_widget.setParent(None) 86 | list_widget.deleteLater() 87 | 88 | while len(self.lists) < count: 89 | self._add_list() 90 | 91 | def _update_list_by_index(self, idx): 92 | list_widget = self.lists[idx] 93 | list_widget.clear() 94 | 95 | dtype = self.types[idx] 96 | for subitem in getattr(dtype, "SubItem", []): 97 | item = QtWidgets.QListWidgetItem( 98 | f"{subitem.name} : {subitem.data_type.name}" 99 | ) 100 | item.setData(Qt.UserRole, subitem.data_type) 101 | list_widget.addItem(item) 102 | 103 | def _add_list(self): 104 | item_list = QtWidgets.QListWidget() 105 | self.lists.append(item_list) 106 | list_index = len(self.lists) - 1 107 | self.layout.addWidget(item_list) 108 | 109 | def changed(current, previous): 110 | if current is not None: 111 | child_dtype = current.data(Qt.UserRole) 112 | child_index = list_index + 1 113 | if not hasattr(child_dtype, "SubItem"): 114 | self._set_list_count(child_index) 115 | else: 116 | self._set_list_count(child_index + 1) 117 | self.types[child_index] = ( 118 | child_dtype.data_type 119 | if hasattr(child_dtype, "data_type") 120 | else child_dtype 121 | ) 122 | self._update_list_by_index(child_index) 123 | 124 | item_list.currentItemChanged.connect(changed) 125 | return item_list 126 | 127 | 128 | def create_types_gui(tmc): 129 | """ 130 | Show the data type information gui 131 | 132 | Parameters 133 | ---------- 134 | tmc : TmcFile, str, pathlib.Path 135 | The tmc file to show 136 | """ 137 | if isinstance(tmc, (str, pathlib.Path)): 138 | tmc = pytmc.parser.parse(tmc) 139 | 140 | interface = TmcTypes(tmc) 141 | interface.setMinimumSize(600, 400) 142 | return interface 143 | 144 | 145 | def main(tmc_file, *, dbd=None): 146 | app = QtWidgets.QApplication([]) 147 | interface = create_types_gui(tmc_file) 148 | interface.show() 149 | sys.exit(app.exec_()) 150 | -------------------------------------------------------------------------------- /pytmc/bin/util.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytmc 4 | from pytmc.parser import TWINCAT_TYPES, TwincatItem 5 | 6 | 7 | def python_debug_session(namespace, message): 8 | debug_namespace = dict(pytmc=pytmc, TwincatItem=TwincatItem) 9 | debug_namespace.update(TWINCAT_TYPES) 10 | debug_namespace.update( 11 | **{k: v for k, v in namespace.items() if not k.startswith("__")} 12 | ) 13 | globals().update(debug_namespace) 14 | 15 | print("\n-- pytmc debug --") 16 | print(message) 17 | print("-- pytmc debug --\n") 18 | 19 | try: 20 | from IPython import embed 21 | except ImportError: 22 | import pdb 23 | 24 | pdb.set_trace() 25 | else: 26 | embed() 27 | 28 | 29 | def heading(text, *, file=sys.stdout): 30 | print(text, file=file) 31 | print("=" * len(text), file=file) 32 | print(file=file) 33 | 34 | 35 | def sub_heading(text, *, file=sys.stdout): 36 | print(text, file=file) 37 | print("-" * len(text), file=file) 38 | print(file=file) 39 | 40 | 41 | def sub_sub_heading(text, level=3, *, use_markdown=False, file=sys.stdout): 42 | if use_markdown: 43 | print("#" * level, text, file=file) 44 | else: 45 | print(" " * level, "-", text, file=file) 46 | print(file=file) 47 | 48 | 49 | def text_block(text, indent=4, markdown_language=None, *, file=sys.stdout): 50 | if markdown_language is not None: 51 | print(f"```{markdown_language}", file=file) 52 | print(text, file=file) 53 | print("```", file=file) 54 | else: 55 | for line in text.splitlines(): 56 | print(" " * indent, line, file=file) 57 | print(file=file) 58 | -------------------------------------------------------------------------------- /pytmc/bin/xmltranslate.py: -------------------------------------------------------------------------------- 1 | """Tool for making XML-formatted files human-readable 2 | 3 | Output uses the following rules: 4 | For a line of xml that appears as the following: 5 | <tag attr=qual...> text </tag> tail 6 | 7 | Note that tail takes precedence over text. 8 | 9 | This program will output the following: 10 | {attrs:qual...} tag text tail 11 | 12 | Tags contained by other tags are indented and printed on the following line. 13 | 14 | This tool was created for exploring .tpy files but is well suited to reading 15 | any xml formatted file. 16 | """ 17 | 18 | import argparse 19 | import textwrap 20 | import xml.etree.ElementTree as ET 21 | 22 | DESCRIPTION = __doc__ 23 | 24 | 25 | def build_arg_parser(parser=None): 26 | if parser is None: 27 | parser = argparse.ArgumentParser() 28 | 29 | parser.description = DESCRIPTION 30 | parser.formatter_class = argparse.RawTextHelpFormatter 31 | 32 | parser.add_argument("input_file", type=str, help="input file") 33 | 34 | parser.add_argument( 35 | "-d", 36 | "--depth", 37 | type=int, 38 | help="Recursive limit for exploring the file", 39 | default=7, 40 | ) 41 | 42 | parser.add_argument( 43 | "-i", 44 | "--indent_size", 45 | type=int, 46 | help="Indent size for output formatting", 47 | default=4, 48 | ) 49 | 50 | return parser 51 | 52 | 53 | def recursive(branch, level=1, indent_size=4, indent=0): 54 | if branch is None: 55 | return 56 | if level == 0: 57 | return 58 | for limb in branch: 59 | # for i in range(indent): 60 | # print(" ",end="") 61 | print( 62 | textwrap.indent( 63 | " ".join( 64 | [str(limb.attrib), str(limb.tag), str(limb.text), str(limb.tail)] 65 | ), 66 | "".join([" "] * indent_size * indent), 67 | ) 68 | ) 69 | recursive(limb, level - 1, indent_size, indent + 1) 70 | 71 | 72 | def main(input_file, *, indent_size=4, depth=7): 73 | tree = ET.parse(input_file) 74 | root = tree.getroot() 75 | recursive(root, depth, indent_size) 76 | -------------------------------------------------------------------------------- /pytmc/code.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code parsing-related utilities 3 | """ 4 | import collections 5 | import logging 6 | import re 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | RE_FUNCTION_BLOCK = re.compile(r"^FUNCTION_BLOCK\s", re.MULTILINE) 12 | RE_PROGRAM = re.compile(r"^PROGRAM\s", re.MULTILINE) 13 | RE_FUNCTION = re.compile(r"^FUNCTION\s", re.MULTILINE) 14 | RE_ACTION = re.compile(r"^ACTION\s", re.MULTILINE) 15 | 16 | 17 | def program_name_from_declaration(declaration): 18 | """ 19 | Determine a program name from a given declaration 20 | 21 | Looks for:: 22 | 23 | PROGRAM <program_name> 24 | """ 25 | for line in declaration.splitlines(): 26 | line = line.strip() 27 | if line.lower().startswith("program "): 28 | return line.split(" ")[1] 29 | 30 | 31 | def determine_block_type(code): 32 | """ 33 | Determine the code block type, looking for e.g., PROGRAM or FUNCTION_BLOCk 34 | 35 | Returns 36 | ------- 37 | {'function_block', 'program', 'function', 'action'} or None 38 | """ 39 | checks = [ 40 | (RE_FUNCTION_BLOCK, "function_block"), 41 | (RE_FUNCTION, "function"), 42 | (RE_PROGRAM, "program"), 43 | (RE_ACTION, "action"), 44 | # TODO: others? 45 | ] 46 | for regex, block_type in checks: 47 | if regex.search(code): 48 | return block_type 49 | 50 | 51 | def lines_between(text, start_marker, end_marker, *, include_blank=False): 52 | """ 53 | From a block of text, yield all lines between `start_marker` and 54 | `end_marker` 55 | 56 | Parameters 57 | ---------- 58 | text : str 59 | The block of text 60 | start_marker : str 61 | The block-starting marker to match 62 | end_marker : str 63 | The block-ending marker to match 64 | include_blank : bool, optional 65 | Skip yielding blank lines 66 | """ 67 | found_start = False 68 | start_marker = start_marker.lower() 69 | end_marker = end_marker.lower() 70 | for line in text.splitlines(): 71 | if line.strip().lower() == start_marker: 72 | found_start = True 73 | elif found_start: 74 | if line.strip().lower() == end_marker: 75 | break 76 | elif line.strip() or include_blank: 77 | yield line 78 | 79 | 80 | def variables_from_declaration(declaration, *, start_marker="var"): 81 | """ 82 | Find all variable declarations given a declaration text block 83 | 84 | Parameters 85 | ---------- 86 | declaration : str 87 | The declaration code 88 | start_marker : str, optional 89 | The default works with POUs, which have a variable block in 90 | VAR/END_VAR. Can be adjusted for GVL 'var_global' as well. 91 | 92 | Returns 93 | ------- 94 | variables : dict 95 | {'var': {'type': 'TYPE', 'spec': '%I'}, ...} 96 | """ 97 | variables = {} 98 | in_type = False 99 | for line in lines_between(declaration, start_marker, "end_var"): 100 | line = line.strip() 101 | if in_type: 102 | if line.lower().startswith("end_type"): 103 | in_type = False 104 | continue 105 | 106 | words = line.split(" ") 107 | if words[0].lower() == "type": 108 | # type <type_name> : 109 | # struct 110 | # ... 111 | # end_struct 112 | # end_type 113 | in_type = True 114 | continue 115 | 116 | # <names> : <dtype> 117 | try: 118 | names, dtype = line.split(":", 1) 119 | except ValueError: 120 | logger.debug("Parsing failed for line: %r", line) 121 | continue 122 | 123 | if ":=" in dtype: 124 | # <names> : <dtype> := <value> 125 | dtype, value = dtype.split(":=", 1) 126 | else: 127 | value = None 128 | 129 | try: 130 | at_idx = names.lower().split(" ").index("at") 131 | except ValueError: 132 | specifiers = [] 133 | else: 134 | # <names> AT <specifiers> : <dtype> := <value> 135 | words = names.split(" ") 136 | specifiers = words[at_idx + 1 :] 137 | names = " ".join(words[:at_idx]) 138 | 139 | var_metadata = { 140 | "type": dtype.strip("; "), 141 | "spec": " ".join(specifiers), 142 | } 143 | if value is not None: 144 | var_metadata["value"] = value.strip("; ") 145 | 146 | for name in names.split(","): 147 | variables[name.strip()] = var_metadata 148 | 149 | return variables 150 | 151 | 152 | def get_pou_call_blocks(declaration: str, implementation: str): 153 | """ 154 | Find all call blocks given a specific POU declaration and implementation. 155 | Note that this function is not "smart". Further calls will be squashed into 156 | one. Control flow is not respected. 157 | 158 | Given the following declaration:: 159 | 160 | PROGRAM Main 161 | VAR 162 | M1: FB_DriveVirtual; 163 | M1Link: FB_NcAxis; 164 | bLimitFwdM1 AT %I*: BOOL; 165 | bLimitBwdM1 AT %I*: BOOL; 166 | 167 | END_VAR 168 | 169 | and implementation:: 170 | 171 | M1Link(En := TRUE); 172 | M1(En := TRUE, 173 | bEnable := TRUE, 174 | bLimitFwd := bLimitFwdM1, 175 | bLimitBwd := bLimitBwdM1, 176 | Axis := M1Link.axis); 177 | 178 | M1(En := FALSE); 179 | 180 | This function would return the following dictionary:: 181 | 182 | {'M1': {'En': 'FALSE', 183 | 'bEnable': 'TRUE', 184 | 'bLimitFwd': 'bLimitFwdM1', 185 | 'bLimitBwd': 'bLimitBwdM1', 186 | 'Axis': 'M1Link.axis'}, 187 | 'M1Link': {'En': 'TRUE'} 188 | } 189 | 190 | """ 191 | variables = variables_from_declaration(declaration) 192 | blocks = collections.defaultdict(dict) 193 | 194 | # Match two groups: (var) := (value) 195 | # Only works for simple variable assignments. 196 | arg_value_re = re.compile(r"([a-zA-Z0-9_]+)\s*:=\s*([a-zA-Z0-9_\.]+)") 197 | 198 | for var in variables: 199 | # Find: ^VAR(.*); 200 | reg = re.compile(r"^\s*" + var + r"\s*\(\s*((?:.*?\n?)+)\)\s*;", re.MULTILINE) 201 | for match in reg.findall(implementation): 202 | call_body = " ".join(line.strip() for line in match.splitlines()) 203 | blocks[var].update(**dict(arg_value_re.findall(call_body))) 204 | 205 | return dict(blocks) 206 | -------------------------------------------------------------------------------- /pytmc/default_settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcdshub/pytmc/94c62dbb0584aba3f3e38ef14ac5186a7425e5ca/pytmc/default_settings/__init__.py -------------------------------------------------------------------------------- /pytmc/default_settings/conf.ini: -------------------------------------------------------------------------------- 1 | # list of recognized TwinCat Types 2 | [TwinCAT Types] 3 | 4 | 5 | # comment 6 | 7 | [Set Two] 8 | Britain 9 | -------------------------------------------------------------------------------- /pytmc/default_settings/unified_ordered_field_list.py: -------------------------------------------------------------------------------- 1 | # single list for all record types dictating field order 2 | 3 | unified_list = [ 4 | "NAME", 5 | "DESC", 6 | "ASG", 7 | "SCAN", 8 | "PINI", 9 | "PHAS", 10 | "EVNT", 11 | "TSE", 12 | "TSEL", 13 | "DTYP", 14 | "DISV", 15 | "DISA", 16 | "SDIS", 17 | "MLOK", 18 | "MLIS", 19 | "BKLNK", 20 | "DISP", 21 | "PROC", 22 | "STAT", 23 | "SEVR", 24 | "NSTA", 25 | "NSEV", 26 | "ACKS", 27 | "ACKT", 28 | "DISS", 29 | "LCNT", 30 | "PACT", 31 | "PUTF", 32 | "RPRO", 33 | "ASP", 34 | "PPN", 35 | "PPNR", 36 | "SPVT", 37 | "RSET", 38 | "DSET", 39 | "DPVT", 40 | "RDES", 41 | "LSET", 42 | "PRIO", 43 | "TPRO", 44 | "BKPT", 45 | "UDF", 46 | "UDFS", 47 | "TIME", 48 | "FLNK", 49 | "VAL", 50 | "NOBT", 51 | "INP", 52 | "ZRVL", 53 | "ONVL", 54 | "TWVL", 55 | "THVL", 56 | "FRVL", 57 | "FVVL", 58 | "SXVL", 59 | "SVVL", 60 | "EIVL", 61 | "NIVL", 62 | "TEVL", 63 | "ELVL", 64 | "TVVL", 65 | "TTVL", 66 | "FTVL", 67 | "FFVL", 68 | "ZRST", 69 | "ONST", 70 | "TWST", 71 | "THST", 72 | "FRST", 73 | "FVST", 74 | "SXST", 75 | "SVST", 76 | "EIST", 77 | "NIST", 78 | "TEST", 79 | "ELST", 80 | "TVST", 81 | "TTST", 82 | "FTST", 83 | "FFST", 84 | "ZRSV", 85 | "ONSV", 86 | "TWSV", 87 | "THSV", 88 | "FRSV", 89 | "FVSV", 90 | "SXSV", 91 | "SVSV", 92 | "EISV", 93 | "NISV", 94 | "TESV", 95 | "ELSV", 96 | "TVSV", 97 | "TTSV", 98 | "FTSV", 99 | "FFSV", 100 | "AFTC", 101 | "AFVL", 102 | "UNSV", 103 | "COSV", 104 | "RVAL", 105 | "ORAW", 106 | "MASK", 107 | "MLST", 108 | "LALM", 109 | "SDEF", 110 | "SHFT", 111 | "SIOL", 112 | "SVAL", 113 | "SIML", 114 | "SIMM", 115 | "SIMS", 116 | "OLDSIMM", 117 | "SSCN", 118 | "SDLY", 119 | "SIMPVT", 120 | "DOL", 121 | "OMSL", 122 | "OUT", 123 | "RBV", 124 | "ORBV", 125 | "IVOA", 126 | "IVOV", 127 | "PREC", 128 | "EGU", 129 | "HOPR", 130 | "LOPR", 131 | "NELM", 132 | "NORD", 133 | "BPTR", 134 | "MPST", 135 | "APST", 136 | "HASH", 137 | "LINR", 138 | "EGUF", 139 | "EGUL", 140 | "AOFF", 141 | "ASLO", 142 | "SMOO", 143 | "HIHI", 144 | "LOLO", 145 | "HIGH", 146 | "LOW", 147 | "HHSV", 148 | "LLSV", 149 | "HSV", 150 | "LSV", 151 | "HYST", 152 | "ADEL", 153 | "MDEL", 154 | "ALST", 155 | "ESLO", 156 | "EOFF", 157 | "ROFF", 158 | "PBRK", 159 | "INIT", 160 | "LBRK", 161 | "OVAL", 162 | "OROC", 163 | "OIF", 164 | "DRVH", 165 | "DRVL", 166 | "PVAL", 167 | "OMOD", 168 | "ZSV", 169 | "OSV", 170 | "ZNAM", 171 | "ONAM", 172 | "RPVT", 173 | "WDPT", 174 | "CALC", 175 | "INPA", 176 | "INPB", 177 | "INPC", 178 | "INPD", 179 | "INPE", 180 | "INPF", 181 | "INPG", 182 | "INPH", 183 | "INPI", 184 | "INPJ", 185 | "INPK", 186 | "INPL", 187 | "A", 188 | "B", 189 | "C", 190 | "D", 191 | "E", 192 | "F", 193 | "G", 194 | "H", 195 | "I", 196 | "J", 197 | "K", 198 | "L", 199 | "LA", 200 | "LB", 201 | "LC", 202 | "LD", 203 | "LE", 204 | "LF", 205 | "LG", 206 | "LH", 207 | "LI", 208 | "LJ", 209 | "LK", 210 | "LL", 211 | "RPCL", 212 | "CLCV", 213 | "INAV", 214 | "INBV", 215 | "INCV", 216 | "INDV", 217 | "INEV", 218 | "INFV", 219 | "INGV", 220 | "INHV", 221 | "INIV", 222 | "INJV", 223 | "INKV", 224 | "INLV", 225 | "OUTV", 226 | "OOPT", 227 | "ODLY", 228 | "DLYA", 229 | "DOPT", 230 | "OCAL", 231 | "OCLV", 232 | "OEVT", 233 | "EPVT", 234 | "POVL", 235 | "ORPC", 236 | "RARM", 237 | "BUSY", 238 | "CSTA", 239 | "CMD", 240 | "ULIM", 241 | "LLIM", 242 | "WDTH", 243 | "SGNL", 244 | "SVL", 245 | "WDOG", 246 | "MCNT", 247 | "SDEL", 248 | "B0", 249 | "B1", 250 | "B2", 251 | "B3", 252 | "B4", 253 | "B5", 254 | "B6", 255 | "B7", 256 | "B8", 257 | "B9", 258 | "BA", 259 | "BB", 260 | "BC", 261 | "BD", 262 | "BE", 263 | "BF", 264 | "SIZV", 265 | "LEN", 266 | "OLEN", 267 | "SELM", 268 | "SELN", 269 | "SELL", 270 | "OFFS", 271 | "LNK0", 272 | "LNK1", 273 | "LNK2", 274 | "LNK3", 275 | "LNK4", 276 | "LNK5", 277 | "LNK6", 278 | "LNK7", 279 | "LNK8", 280 | "LNK9", 281 | "LNKA", 282 | "LNKB", 283 | "LNKC", 284 | "LNKD", 285 | "LNKE", 286 | "LNKF", 287 | "OLDN", 288 | "DLY0", 289 | "DOL0", 290 | "DO0", 291 | "DLY1", 292 | "DOL1", 293 | "DO1", 294 | "DLY2", 295 | "DOL2", 296 | "DO2", 297 | "DLY3", 298 | "DOL3", 299 | "DO3", 300 | "DLY4", 301 | "DOL4", 302 | "DO4", 303 | "DLY5", 304 | "DOL5", 305 | "DO5", 306 | "DLY6", 307 | "DOL6", 308 | "DO6", 309 | "DLY7", 310 | "DOL7", 311 | "DO7", 312 | "DLY8", 313 | "DOL8", 314 | "DO8", 315 | "DLY9", 316 | "DOL9", 317 | "DO9", 318 | "DOLA", 319 | "DOA", 320 | "DLYB", 321 | "DOLB", 322 | "DOB", 323 | "DLYC", 324 | "DOLC", 325 | "DOC", 326 | "DLYD", 327 | "DOLD", 328 | "DOD", 329 | "DLYE", 330 | "DOLE", 331 | "DOE", 332 | "DLYF", 333 | "DOLF", 334 | "DOF", 335 | "INAM", 336 | "LFLG", 337 | "SUBL", 338 | "SNAM", 339 | "SADR", 340 | "CADR", 341 | "BRSV", 342 | "EFLG", 343 | "INPM", 344 | "INPN", 345 | "INPO", 346 | "INPP", 347 | "INPQ", 348 | "INPR", 349 | "INPS", 350 | "INPT", 351 | "INPU", 352 | "M", 353 | "N", 354 | "O", 355 | "P", 356 | "Q", 357 | "R", 358 | "S", 359 | "T", 360 | "U", 361 | "FTA", 362 | "FTB", 363 | "FTC", 364 | "FTD", 365 | "FTE", 366 | "FTF", 367 | "FTG", 368 | "FTH", 369 | "FTI", 370 | "FTJ", 371 | "FTK", 372 | "FTL", 373 | "FTM", 374 | "FTN", 375 | "FTO", 376 | "FTP", 377 | "FTQ", 378 | "FTR", 379 | "FTS", 380 | "FTT", 381 | "FTU", 382 | "NOA", 383 | "NOB", 384 | "NOC", 385 | "NOD", 386 | "NOE", 387 | "NOF", 388 | "NOG", 389 | "NOH", 390 | "NOI", 391 | "NOJ", 392 | "NOK", 393 | "NOL", 394 | "NOM", 395 | "NON", 396 | "NOO", 397 | "NOP", 398 | "NOQ", 399 | "NOR", 400 | "NOS", 401 | "NOT", 402 | "NOU", 403 | "NEA", 404 | "NEB", 405 | "NEC", 406 | "NED", 407 | "NEE", 408 | "NEF", 409 | "NEG", 410 | "NEH", 411 | "NEI", 412 | "NEJ", 413 | "NEK", 414 | "NEL", 415 | "NEM", 416 | "NEN", 417 | "NEO", 418 | "NEP", 419 | "NEQ", 420 | "NER", 421 | "NES", 422 | "NET", 423 | "NEU", 424 | "OUTA", 425 | "OUTB", 426 | "OUTC", 427 | "OUTD", 428 | "OUTE", 429 | "OUTF", 430 | "OUTG", 431 | "OUTH", 432 | "OUTI", 433 | "OUTJ", 434 | "OUTK", 435 | "OUTL", 436 | "OUTM", 437 | "OUTN", 438 | "OUTO", 439 | "OUTP", 440 | "OUTQ", 441 | "OUTR", 442 | "OUTS", 443 | "OUTT", 444 | "OUTU", 445 | "VALA", 446 | "VALB", 447 | "VALC", 448 | "VALD", 449 | "VALE", 450 | "VALF", 451 | "VALG", 452 | "VALH", 453 | "VALI", 454 | "VALJ", 455 | "VALK", 456 | "VALL", 457 | "VALM", 458 | "VALN", 459 | "VALO", 460 | "VALP", 461 | "VALQ", 462 | "VALR", 463 | "VALS", 464 | "VALT", 465 | "VALU", 466 | "OVLA", 467 | "OVLB", 468 | "OVLC", 469 | "OVLD", 470 | "OVLE", 471 | "OVLF", 472 | "OVLG", 473 | "OVLH", 474 | "OVLI", 475 | "OVLJ", 476 | "OVLK", 477 | "OVLL", 478 | "OVLM", 479 | "OVLN", 480 | "OVLO", 481 | "OVLP", 482 | "OVLQ", 483 | "OVLR", 484 | "OVLS", 485 | "OVLT", 486 | "OVLU", 487 | "FTVA", 488 | "FTVB", 489 | "FTVC", 490 | "FTVD", 491 | "FTVE", 492 | "FTVF", 493 | "FTVG", 494 | "FTVH", 495 | "FTVI", 496 | "FTVJ", 497 | "FTVK", 498 | "FTVM", 499 | "FTVN", 500 | "FTVO", 501 | "FTVP", 502 | "FTVQ", 503 | "FTVR", 504 | "FTVS", 505 | "FTVT", 506 | "FTVU", 507 | "NOVA", 508 | "NOVB", 509 | "NOVC", 510 | "NOVD", 511 | "NOVE", 512 | "NOVF", 513 | "NOVG", 514 | "NOVH", 515 | "NOVI", 516 | "NOVJ", 517 | "NOVK", 518 | "NOVL", 519 | "NOVM", 520 | "NOVN", 521 | "NOVO", 522 | "NOVP", 523 | "NOVQ", 524 | "NOVR", 525 | "NOVS", 526 | "NOVT", 527 | "NOVU", 528 | "NEVA", 529 | "NEVB", 530 | "NEVC", 531 | "NEVD", 532 | "NEVE", 533 | "NEVF", 534 | "NEVG", 535 | "NEVH", 536 | "NEVI", 537 | "NEVJ", 538 | "NEVK", 539 | "NEVL", 540 | "NEVM", 541 | "NEVN", 542 | "NEVO", 543 | "NEVP", 544 | "NEVQ", 545 | "NEVR", 546 | "NEVS", 547 | "NEVT", 548 | "NEVU", 549 | "ONVA", 550 | "ONVB", 551 | "ONVC", 552 | "ONVD", 553 | "ONVE", 554 | "ONVF", 555 | "ONVG", 556 | "ONVH", 557 | "ONVI", 558 | "ONVJ", 559 | "ONVK", 560 | "ONVM", 561 | "ONVN", 562 | "ONVO", 563 | "ONVP", 564 | "ONVQ", 565 | "ONVR", 566 | "ONVS", 567 | "ONVT", 568 | "ONVU", 569 | "MALM", 570 | "INDX", 571 | "RES", 572 | "ALG", 573 | "BALG", 574 | "NSAM", 575 | "IHIL", 576 | "ILIL", 577 | "OFF", 578 | "NUSE", 579 | "OUSE", 580 | "SPTR", 581 | "WPTR", 582 | "CVB", 583 | "INX", 584 | "LABL", 585 | "WFLG", 586 | "OFLG", 587 | "FMT", 588 | "IVLS", 589 | "INP0", 590 | "INP1", 591 | "INP2", 592 | "INP3", 593 | "INP4", 594 | "INP5", 595 | "INP6", 596 | "INP7", 597 | "INP8", 598 | "INP9", 599 | "NVL", 600 | "NLST", 601 | ] 602 | 603 | unified_lookup_list = {field: index for index, field in enumerate(unified_list)} 604 | -------------------------------------------------------------------------------- /pytmc/defaults.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | 4 | import pkg_resources 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | config = configparser.ConfigParser(allow_no_value=True) 10 | config.read(pkg_resources.resource_filename("pytmc", "default_settings/conf.ini")) 11 | -------------------------------------------------------------------------------- /pytmc/linter.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pyPDB.dbd.yacc as _yacc 4 | import pyPDB.dbdlint as _dbdlint 5 | from pyPDB.dbdlint import DBSyntaxError 6 | 7 | MAX_RECORD_LENGTH = int(os.environ.get("EPICS_MAX_RECORD_LENGTH", "60")) 8 | 9 | 10 | class LinterResults(_dbdlint.Results): 11 | """ 12 | Container for dbdlint results, with easier-to-access attributes 13 | 14 | Extends pyPDB.dbdlint.Results 15 | 16 | Each error or warning has dictionary keys:: 17 | 18 | {name, message, file, line, raw_message, format_args} 19 | 20 | Attributes 21 | ---------- 22 | errors : list 23 | List of errors found 24 | warnings : list 25 | List of warnings found 26 | """ 27 | 28 | def __init__(self, args): 29 | super().__init__(args) 30 | self.errors = [] 31 | self.warnings = [] 32 | 33 | def _record_warning_or_error(self, result_list, name, msg, args): 34 | result_list.append( 35 | { 36 | "name": name, 37 | "message": msg % args, 38 | "file": self.node.fname, 39 | "line": self.node.lineno, 40 | "raw_message": msg, 41 | "format_args": args, 42 | } 43 | ) 44 | 45 | def err(self, name, msg, *args): 46 | super().err(name, msg, *args) 47 | self._record_warning_or_error(self.errors, name, msg, args) 48 | 49 | def warn(self, name, msg, *args): 50 | super().warn(name, msg, *args) 51 | if name in self._warns: 52 | self._record_warning_or_error(self.warnings, name, msg, args) 53 | 54 | @property 55 | def success(self): 56 | """ 57 | Returns 58 | ------- 59 | success : bool 60 | True if the linting process succeeded without errors 61 | """ 62 | return not len(self.errors) 63 | 64 | 65 | class DbdFile: 66 | """ 67 | An expanded EPICS dbd file 68 | 69 | Parameters 70 | ---------- 71 | fn : str or file 72 | dbd filename 73 | 74 | Attributes 75 | ---------- 76 | filename : str 77 | The dbd filename 78 | parsed : list 79 | pyPDB parsed dbd nodes 80 | """ 81 | 82 | def __init__(self, fn): 83 | if hasattr(fn, "read"): 84 | self.filename = getattr(fn, "name", None) 85 | contents = fn.read() 86 | else: 87 | self.filename = str(fn) 88 | with open(fn) as f: 89 | contents = f.read() 90 | 91 | self.parsed = _yacc.parse(contents) 92 | 93 | 94 | def lint_db( 95 | dbd, 96 | db, 97 | *, 98 | full=True, 99 | warn_ext_links=False, 100 | warn_bad_fields=True, 101 | warn_rec_append=False, 102 | warn_quoted=False, 103 | warn_varint=True, 104 | warn_spec_comm=True, 105 | ): 106 | """ 107 | Lint a db (database) file using its database definition file (dbd) using 108 | pyPDB. 109 | 110 | Parameters 111 | ---------- 112 | dbd : DbdFile or str 113 | The database definition file; filename or pre-loaded DbdFile 114 | db : str 115 | The database filename or text 116 | full : bool, optional 117 | Validate as a complete database 118 | warn_quoted : bool, optional 119 | A node argument isn't quoted 120 | warn_varint : bool, optional 121 | A variable(varname) node which doesn't specify a type, which defaults 122 | to 'int' 123 | warn_spec_comm : bool, optional 124 | Syntax error in special #: comment line 125 | warn_ext_link : bool, optional 126 | A DB/CA link to a PV which is not defined. Add '#: external("pv.FLD") 127 | warn_bad_field : bool, optional 128 | Unable to validate record instance field due to a previous error 129 | (missing recordtype). 130 | warn_rec_append : bool, optional 131 | Not using Base >=3.15 style recordtype "*" when appending/overwriting 132 | record instances 133 | 134 | Raises 135 | ------ 136 | DBSyntaxError 137 | When a syntax issue is discovered. Note that this exception contains 138 | file and line number information (attributes: fname, lineno, results) 139 | 140 | Returns 141 | ------- 142 | results : LinterResults 143 | """ 144 | args = [] 145 | if warn_ext_links: 146 | args.append("-Wext-link") 147 | if warn_bad_fields: 148 | args.append("-Wbad-field") 149 | if warn_rec_append: 150 | args.append("-Wrec-append") 151 | 152 | if not warn_quoted: 153 | args.append("-Wno-quoted") 154 | if not warn_varint: 155 | args.append("-Wno-varint") 156 | if not warn_spec_comm: 157 | args.append("-Wno-spec-comm") 158 | 159 | if full: 160 | args.append("-F") 161 | else: 162 | args.append("-P") 163 | 164 | dbd_file = dbd if isinstance(dbd, DbdFile) else DbdFile(dbd) 165 | 166 | args = _dbdlint.getargs([dbd_file.filename, db, *args]) 167 | 168 | results = LinterResults(args) 169 | 170 | if os.path.exists(db): 171 | with open(db) as f: 172 | db_content = f.read() 173 | else: 174 | db_content = db 175 | db = "<string>" 176 | 177 | try: 178 | _dbdlint.walk(dbd_file.parsed, _dbdlint.dbdtree, results) 179 | parsed_db = _yacc.parse(db_content, file=db) 180 | _dbdlint.walk(parsed_db, _dbdlint.dbdtree, results) 181 | except DBSyntaxError as ex: 182 | ex.errors = results.errors 183 | ex.warnings = results.warnings 184 | raise 185 | 186 | return results 187 | -------------------------------------------------------------------------------- /pytmc/templates/EPICS_proto_template.proto: -------------------------------------------------------------------------------- 1 | {{header}} 2 | 3 | {% for proto in master_list %} 4 | {{proto.name}}{ 5 | out "{{proto.out_field}}"; 6 | in "{{proto.in_field}}"; 7 | {% if proto.has_init %} 8 | @init { {{proto.init}}; }; 9 | {% endif %} 10 | } 11 | 12 | {% endfor %} 13 | -------------------------------------------------------------------------------- /pytmc/templates/asyn_standard_file.jinja2: -------------------------------------------------------------------------------- 1 | {% for r in records%} 2 | {{r}} 3 | 4 | {% endfor %} 5 | -------------------------------------------------------------------------------- /pytmc/templates/asyn_standard_record.jinja2: -------------------------------------------------------------------------------- 1 | record({{record.record_type}}, "{{record.pvname}}") { 2 | {% if record.long_description %} 3 | # {{ record.long_description }} 4 | {% endif %} 5 | {% for alias in record.aliases %} 6 | alias("{{alias}}") 7 | {% endfor %} 8 | {% block add_fields %}{% endblock %} 9 | {% for f in record.fields%} 10 | field({{f}}, "{{record.fields[f]}}") 11 | {% endfor %} 12 | {% if record.autosave['pass1'] %} 13 | info(autosaveFields, "{{ record.autosave['pass1'] | sort | join(' ') }}") 14 | {% endif %} 15 | {% if record.autosave['pass0'] %} 16 | info(autosaveFields_pass0, "{{ record.autosave['pass0'] | sort | join(' ') }}") 17 | {% endif %} 18 | {% if record.archive_settings %} 19 | {% if record.archive_settings['method'] == 'scan' and record.archive_settings['seconds'] == 1 %} 20 | info(archive, "{{ record.archive_settings['fields'] | join(' ') }}") 21 | {% else %} 22 | info(archive, "{{ record.archive_settings['method'] }} {{ record.archive_settings['seconds'] }}: {{ record.archive_settings['fields'] | join(' ') }}") 23 | {% endif %} 24 | {% endif %} 25 | {% if record.direction == "input" %} 26 | field(ASG, "NO_WRITE") 27 | {% endif %} 28 | } 29 | -------------------------------------------------------------------------------- /pytmc/templates/stcmd_default.cmd: -------------------------------------------------------------------------------- 1 | #!{{hashbang}} 2 | 3 | < envPaths 4 | epicsEnvSet("IOCNAME", "{{name}}" ) 5 | epicsEnvSet("ENGINEER", "{{user}}" ) 6 | epicsEnvSet("LOCATION", "{{prefix}}" ) 7 | epicsEnvSet("IOCSH_PS1", "$(IOCNAME)> " ) 8 | 9 | cd "$(TOP)" 10 | 11 | # Run common startup commands for linux soft IOC's 12 | < /reg/d/iocCommon/All/pre_linux.cmd 13 | 14 | # Register all support components 15 | dbLoadDatabase("dbd/{{binary_name}}.dbd") 16 | {{binary_name}}_registerRecordDeviceDriver(pdbbase) 17 | 18 | cd "$(TOP)/db" 19 | 20 | epicsEnvSet("ASYN_PORT", "{{asyn_port}}") 21 | epicsEnvSet("IPADDR", "{{plc_ip}}") 22 | epicsEnvSet("AMSID", "{{plc_ams_id}}") 23 | epicsEnvSet("IPPORT", "{{plc_ads_port}}") 24 | 25 | adsAsynPortDriverConfigure("$(ASYN_PORT)","$(IPADDR)","$(AMSID)","$(IPPORT)", 1000, 0, 0, 50, 100, 1000, 0) 26 | 27 | {% if motors %} 28 | epicsEnvSet("MOTOR_PORT", "{{motor_port}}") 29 | epicsEnvSet("PREFIX", "{{prefix}}{{delim}}") 30 | epicsEnvSet("ECM_NUMAXES", "{{motors|length}}") 31 | epicsEnvSet("NUMAXES", "{{motors|length}}") 32 | 33 | EthercatMCCreateController("$(MOTOR_PORT)", "$(ASYN_PORT)", "$(NUMAXES)", "200", "1000") 34 | 35 | #define ASYN_TRACE_ERROR 0x0001 36 | #define ASYN_TRACEIO_DEVICE 0x0002 37 | #define ASYN_TRACEIO_FILTER 0x0004 38 | #define ASYN_TRACEIO_DRIVER 0x0008 39 | #define ASYN_TRACE_FLOW 0x0010 40 | #define ASYN_TRACE_WARNING 0x0020 41 | #define ASYN_TRACE_INFO 0x0040 42 | asynSetTraceMask("$(ASYN_PORT)", -1, 0x41) 43 | 44 | #define ASYN_TRACEIO_NODATA 0x0000 45 | #define ASYN_TRACEIO_ASCII 0x0001 46 | #define ASYN_TRACEIO_ESCAPE 0x0002 47 | #define ASYN_TRACEIO_HEX 0x0004 48 | asynSetTraceIOMask("$(ASYN_PORT)", -1, 2) 49 | 50 | #define ASYN_TRACEINFO_TIME 0x0001 51 | #define ASYN_TRACEINFO_PORT 0x0002 52 | #define ASYN_TRACEINFO_SOURCE 0x0004 53 | #define ASYN_TRACEINFO_THREAD 0x0008 54 | asynSetTraceInfoMask("$(ASYN_PORT)", -1, 5) 55 | 56 | #define AMPLIFIER_ON_FLAG_CREATE_AXIS 1 57 | #define AMPLIFIER_ON_FLAG_WHEN_HOMING 2 58 | #define AMPLIFIER_ON_FLAG_USING_CNEN 4 59 | 60 | {% for motor in motors | sort(attribute='nc_axis.axis_number') %} 61 | epicsEnvSet("AXIS_NO", "{{motor.nc_axis.axis_number}}") 62 | epicsEnvSet("MOTOR_PREFIX", "{{motor|epics_prefix}}") 63 | epicsEnvSet("MOTOR_NAME", "{{motor|epics_suffix}}") 64 | epicsEnvSet("DESC", "{{motor.name}} / {{motor.nc_axis.name}}") 65 | epicsEnvSet("EGU", "{{motor.nc_axis.units}}") 66 | epicsEnvSet("PREC", "{{motor|pragma('precision', 3) }}") 67 | epicsEnvSet("AXISCONFIG", "{{motor|pragma('axisconfig', '')}}") 68 | epicsEnvSet("ECAXISFIELDINIT", "{{motor|pragma('additional_fields', '') }}") 69 | epicsEnvSet("AMPLIFIER_FLAGS", "{{motor|pragma('amplifier_flags', '') }}") 70 | 71 | EthercatMCCreateAxis("$(MOTOR_PORT)", "$(AXIS_NO)", "$(AMPLIFIER_FLAGS)", "$(AXISCONFIG)") 72 | dbLoadRecords("EthercatMC.template", "PREFIX=$(MOTOR_PREFIX), MOTOR_NAME=$(MOTOR_NAME), R=$(MOTOR_NAME)-, MOTOR_PORT=$(MOTOR_PORT), ASYN_PORT=$(ASYN_PORT), AXIS_NO=$(AXIS_NO), DESC=$(DESC), PREC=$(PREC) $(ECAXISFIELDINIT)") 73 | dbLoadRecords("EthercatMCreadback.template", "PREFIX=$(MOTOR_PREFIX), MOTOR_NAME=$(MOTOR_NAME), R=$(MOTOR_NAME)-, MOTOR_PORT=$(MOTOR_PORT), ASYN_PORT=$(ASYN_PORT), AXIS_NO=$(AXIS_NO), DESC=$(DESC), PREC=$(PREC) ") 74 | dbLoadRecords("EthercatMCdebug.template", "PREFIX=$(MOTOR_PREFIX), MOTOR_NAME=$(MOTOR_NAME), MOTOR_PORT=$(MOTOR_PORT), AXIS_NO=$(AXIS_NO), PREC=3") 75 | 76 | {% endfor %} 77 | {% endif %} 78 | {% for db in additional_db_files %} 79 | dbLoadRecords("{{ db.file }}", "{{ db.macros }}") 80 | 81 | {% endfor %} 82 | cd "$(TOP)" 83 | 84 | dbLoadRecords("db/iocAdmin.db", "P={{prefix}},IOC={{prefix}}" ) 85 | dbLoadRecords("db/save_restoreStatus.db", "P={{prefix}},IOC={{name}}" ) 86 | 87 | # Setup autosave 88 | set_savefile_path( "$(IOC_DATA)/$(IOC)/autosave" ) 89 | set_requestfile_path( "$(TOP)/autosave" ) 90 | save_restoreSet_status_prefix( "{{prefix}}:" ) 91 | save_restoreSet_IncompleteSetsOk( 1 ) 92 | save_restoreSet_DatedBackupFiles( 1 ) 93 | set_pass0_restoreFile( "$(IOC).sav" ) 94 | set_pass1_restoreFile( "$(IOC).sav" ) 95 | 96 | # Initialize the IOC and start processing records 97 | iocInit() 98 | 99 | # Start autosave backups 100 | create_monitor_set( "$(IOC).req", 5, "" ) 101 | 102 | # All IOCs should dump some common info after initial startup. 103 | < /reg/d/iocCommon/All/post_linux.cmd 104 | -------------------------------------------------------------------------------- /pytmc/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcdshub/pytmc/94c62dbb0584aba3f3e38ef14ac5186a7425e5ca/pytmc/tests/__init__.py -------------------------------------------------------------------------------- /pytmc/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pathlib 3 | 4 | import pytest 5 | 6 | import pytmc 7 | from pytmc import linter, parser 8 | 9 | logger = logging.getLogger(__name__) 10 | TEST_PATH = pathlib.Path(__file__).parent 11 | DBD_FILE = TEST_PATH / "ads.dbd" 12 | 13 | TMC_ROOT = TEST_PATH / "tmc_files" 14 | TMC_FILES = list(TMC_ROOT.glob("*.tmc")) 15 | INVALID_TMC_FILES = list((TMC_ROOT / "invalid").glob("*.tmc")) 16 | PROJ_ROOT = TEST_PATH / "projects" 17 | TSPROJ_PROJECTS = list(str(fn) for fn in TEST_PATH.glob("**/*.tsproj")) 18 | TEMPLATES = TEST_PATH / "templates" 19 | 20 | 21 | @pytest.fixture(scope="module") 22 | def dbd_file(): 23 | return pytmc.linter.DbdFile(DBD_FILE) 24 | 25 | 26 | @pytest.fixture(params=TMC_FILES, ids=[f.name for f in TMC_FILES]) 27 | def tmc_filename(request): 28 | return request.param 29 | 30 | 31 | @pytest.fixture(scope="module") 32 | def tmc_xtes_sxr_plc(): 33 | """ 34 | generic .tmc file 35 | """ 36 | return TMC_ROOT / "xtes_sxr_plc.tmc" 37 | 38 | 39 | @pytest.fixture(scope="module") 40 | def tmc_arbiter_plc(): 41 | """ 42 | generic .tmc file 43 | """ 44 | return TMC_ROOT / "ArbiterPLC.tmc" 45 | 46 | 47 | @pytest.fixture(scope="module") 48 | def tmc_pmps_dev_arbiter(): 49 | """ 50 | .tmc file containing pinned global variables 51 | """ 52 | path = PROJ_ROOT / "pmps-dev-arbiter/Arbiter/ArbiterPLC/ArbiterPLC.tmc" 53 | return path 54 | 55 | 56 | @pytest.fixture(params=TSPROJ_PROJECTS) 57 | def project_filename(request): 58 | return request.param 59 | 60 | 61 | def _generate_project_and_plcs(): 62 | for project_filename in TSPROJ_PROJECTS: 63 | project = parser.parse(project_filename) 64 | for plc_name in project.plcs_by_name: 65 | yield project_filename, plc_name 66 | 67 | 68 | @pytest.fixture( 69 | params=[ 70 | pytest.param((project_filename, plc_name), id=f"{project_filename} {plc_name}") 71 | for project_filename, plc_name in _generate_project_and_plcs() 72 | ] 73 | ) 74 | def project_and_plc(request): 75 | class Item: 76 | project = request.param[0] 77 | plc_name = request.param[1] 78 | 79 | return Item 80 | 81 | 82 | @pytest.fixture(scope="function") 83 | def project(project_filename): 84 | return parser.parse(project_filename) 85 | 86 | 87 | def lint_record(dbd_file, record): 88 | assert record.valid 89 | linted = linter.lint_db(dbd=dbd_file, db=record.render()) 90 | assert not len(linted.errors) 91 | 92 | 93 | def get_real_motor_symbols(project): 94 | """ 95 | Motor symbols minus arrays of motors 96 | """ 97 | return [ 98 | motor for motor in project.find(parser.Symbol_ST_MotionStage) 99 | if not hasattr(motor, 'ArrayInfo') 100 | ] 101 | -------------------------------------------------------------------------------- /pytmc/tests/static_routes.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <TcConfig xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> 3 | <RemoteConnections> 4 | <Route> 5 | <Name>LAMP-VACUUM</Name> 6 | <Address>172.21.37.140</Address> 7 | <NetId>5.21.50.18.1.1</NetId> 8 | <Type>TCP_IP</Type> 9 | </Route> 10 | <Route> 11 | <Name>AMO-BASE</Name> 12 | <Address>172.21.37.114</Address> 13 | <NetId>5.17.65.196.1.1</NetId> 14 | <Type>TCP_IP</Type> 15 | </Route> 16 | </RemoteConnections> 17 | </TcConfig> 18 | -------------------------------------------------------------------------------- /pytmc/tests/templates/basic_test.txt: -------------------------------------------------------------------------------- 1 | {% for project in projects %}{{ project }}{% endfor %} 2 | -------------------------------------------------------------------------------- /pytmc/tests/templates/smoke_test.txt: -------------------------------------------------------------------------------- 1 | {% for project_path, project in projects.items() %} 2 | {% set nc = get_nc(project) %} 3 | {% for box in get_boxes(project) %} 4 | box: {{ box.attributes["Id"] }} 5 | {% endfor %} 6 | 7 | {% for dt in get_data_types(project) %} 8 | data type: {{ dt }} 9 | {% endfor %} 10 | 11 | {% for link in get_links(project) %} 12 | link: {{ link.a }} 13 | {% endfor %} 14 | 15 | {% for plc in project.plcs %} 16 | {% set results = get_linter_results(plc) %} 17 | Pragma count: {{ results.pragma_count }} 18 | 19 | {% for lib in get_library_versions(plc) %} 20 | library: {{ lib }} 21 | {% endfor %} 22 | 23 | {% endfor %} 24 | {% endfor %} 25 | -------------------------------------------------------------------------------- /pytmc/tests/test_archive.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import pytmc 4 | import pytmc.pragmas 5 | import pytmc.record 6 | 7 | from .test_xml_collector import make_mock_twincatitem, make_mock_type 8 | 9 | 10 | def get_record_package(data_type, io, pragma): 11 | pragma = "; ".join(f"{key}: {value}" for key, value in pragma.items()) 12 | symbol = make_mock_twincatitem( 13 | name="a", 14 | data_type=data_type, 15 | pragma=f"pv: PVNAME; io: {io}; {pragma}", 16 | ) 17 | return list(pytmc.pragmas.record_packages_from_symbol(symbol))[0] 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "data_type, io, pragma, expected", 22 | [ 23 | pytest.param( 24 | make_mock_type("INT"), 25 | "io", 26 | dict(archive="2s"), 27 | [ 28 | "\t".join(("PVNAME_RBV.VAL", "2", "scan")), 29 | "\t".join(("PVNAME.VAL", "2", "scan")), 30 | ], 31 | id="2s_io_default", 32 | ), 33 | pytest.param( 34 | make_mock_type("INT"), 35 | "i", 36 | dict(archive="1s"), 37 | [ 38 | "\t".join(("PVNAME_RBV.VAL", "1", "scan")), 39 | ], 40 | id="1s_i_default", 41 | ), 42 | pytest.param( 43 | make_mock_type("INT"), 44 | "i", 45 | dict(archive="1s monitor"), 46 | [ 47 | "\t".join(("PVNAME_RBV.VAL", "1", "monitor")), 48 | ], 49 | id="1s_i_monitor", 50 | ), 51 | pytest.param( 52 | make_mock_type("INT"), 53 | "i", 54 | dict(archive="1s scan"), 55 | [ 56 | "\t".join(("PVNAME_RBV.VAL", "1", "scan")), 57 | ], 58 | id="1s_i_scan", 59 | ), 60 | pytest.param( 61 | make_mock_type("INT"), 62 | "i", 63 | dict(archive="1s test"), 64 | [], 65 | id="bad_pragma", 66 | marks=pytest.mark.xfail, 67 | ), 68 | pytest.param( 69 | make_mock_type("INT", length=100, is_array=True), 70 | "i", 71 | dict(archive="1s scan"), 72 | [ 73 | "\t".join(("PVNAME_RBV.VAL", "1", "scan")), 74 | ], 75 | id="small_array_100", 76 | ), 77 | pytest.param( 78 | make_mock_type( 79 | "INT", length=pytmc.record.MAX_ARCHIVE_ELEMENTS + 1, is_array=True 80 | ), 81 | "i", 82 | dict(archive="1s scan"), 83 | [], 84 | id="large_array_1025", 85 | ), 86 | pytest.param( 87 | make_mock_type("INT"), 88 | "i", 89 | dict(archive="1s scan", update="2s"), 90 | [ 91 | "\t".join(("PVNAME_RBV.VAL", "2", "scan")), 92 | ], 93 | id="clamp_slow_poll_rate", 94 | ), 95 | ], 96 | ) 97 | def test_archive(data_type, io, pragma, expected): 98 | record_package = get_record_package(data_type, io, pragma) 99 | print(record_package) 100 | archive_settings = list(pytmc.record.generate_archive_settings([record_package])) 101 | assert archive_settings == expected 102 | -------------------------------------------------------------------------------- /pytmc/tests/test_commandline.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import pytest 5 | 6 | import pytmc 7 | import pytmc.bin.pytmc as pytmc_main 8 | from pytmc.bin.code import main as code_main 9 | from pytmc.bin.db import main as db_main 10 | from pytmc.bin.debug import create_debug_gui 11 | from pytmc.bin.pragmalint import main as pragmalint_main 12 | from pytmc.bin.stcmd import main as stcmd_main 13 | from pytmc.bin.summary import main as summary_main 14 | from pytmc.bin.template import main as template_main 15 | from pytmc.bin.types import create_types_gui 16 | from pytmc.bin.xmltranslate import main as xmltranslate_main 17 | 18 | from .conftest import TEMPLATES 19 | 20 | 21 | def test_help_main(monkeypatch): 22 | monkeypatch.setattr(sys, "argv", ["--help"]) 23 | pytmc_main.main() 24 | 25 | 26 | @pytest.mark.parametrize("subcommand", pytmc_main.COMMANDS.keys()) 27 | def test_help_module(monkeypatch, subcommand): 28 | monkeypatch.setattr(sys, "argv", [subcommand, "--help"]) 29 | with pytest.raises(SystemExit): 30 | pytmc_main.main() 31 | 32 | 33 | def test_summary(project_filename): 34 | summary_main( 35 | project_filename, 36 | show_all=True, 37 | show_code=True, 38 | use_markdown=True, 39 | show_types=True, 40 | filter_types=["*"], 41 | ) 42 | 43 | 44 | @pytest.fixture() 45 | def project_filename_linter_success(project_filename): 46 | """Return True if project should pass the linter test""" 47 | if "lcls-twincat-pmps.tsproj" in project_filename: 48 | return False 49 | return True 50 | 51 | 52 | def test_pragmalint(project_filename, project_filename_linter_success): 53 | if not project_filename_linter_success: 54 | pytest.xfail("Project's current state does not satisfy linter") 55 | pragmalint_main(project_filename, verbose=True, use_markdown=True) 56 | 57 | 58 | def test_stcmd(project_and_plc): 59 | project_filename = project_and_plc.project 60 | plc_name = project_and_plc.plc_name 61 | allow_errors = any( 62 | name in project_filename 63 | for name in ("lcls-twincat-motion", "XtesSxrPlc", "plc-kfe-gmd-vac") 64 | ) 65 | stcmd_main(project_filename, plc_name=plc_name, allow_errors=allow_errors) 66 | 67 | 68 | def test_xmltranslate(project_filename): 69 | xmltranslate_main(project_filename) 70 | 71 | 72 | def test_db(tmc_filename): 73 | db_main(tmc_filename, archive_file=sys.stderr) 74 | 75 | 76 | @pytest.mark.xfail 77 | def test_db_archive_bad_args(tmc_filename): 78 | db_main(tmc_filename, archive_file=sys.stderr, no_archive_file=True) 79 | 80 | 81 | def test_types(qtbot, tmc_filename): 82 | widget = create_types_gui(tmc_filename) 83 | qtbot.addWidget(widget) 84 | 85 | 86 | def test_debug(qtbot, tmc_filename): 87 | widget = create_debug_gui(tmc_filename) 88 | qtbot.addWidget(widget) 89 | 90 | 91 | def test_code(project_filename): 92 | code_main(project_filename) 93 | 94 | 95 | def test_template_basic(project_filename): 96 | template = str(TEMPLATES / "basic_test.txt") 97 | templated = template_main( 98 | [project_filename], 99 | templates=[template + os.pathsep], 100 | ) 101 | 102 | print("templated", templated) 103 | assert templated[template] == project_filename 104 | 105 | 106 | @pytest.mark.parametrize( 107 | "template", 108 | [ 109 | pytest.param(str(TEMPLATES / "smoke_test.txt"), id="helpers"), 110 | ], 111 | ) 112 | def test_template_smoke(project_filename, template): 113 | templated = template_main( 114 | [project_filename], 115 | templates=[template + os.pathsep], 116 | ) 117 | 118 | print("templated", templated) 119 | 120 | 121 | @pytest.mark.parametrize( 122 | "argument, input_filename, output_filename", 123 | [ 124 | pytest.param( 125 | "a", "a", "", 126 | id="template-file-to-stdout" 127 | ), 128 | pytest.param( 129 | "-", "-", "", 130 | id="stdin-to-stdout", 131 | ), 132 | pytest.param( 133 | "a:b", "a", "b", 134 | ), 135 | pytest.param( 136 | "C:/a/b/c.def:D:/d/e/f.ghi", 137 | "C:/a/b/c.def", 138 | "D:/d/e/f.ghi", 139 | ), 140 | pytest.param( 141 | "//a/b/c.def:D:/d/e/f.ghi", 142 | "//a/b/c.def", 143 | "D:/d/e/f.ghi", 144 | ), 145 | pytest.param( 146 | "/tmp/:messed:up:filename:/tmp/a/b:c:d", 147 | "/tmp/:messed:up:filename", 148 | "/tmp/a/b:c:d", 149 | ), 150 | pytest.param( 151 | "-:output_fn", 152 | "-", 153 | "output_fn", 154 | ), 155 | ], 156 | ) 157 | def test_filename_split( 158 | argument: str, 159 | input_filename: str, 160 | output_filename: str, 161 | monkeypatch, 162 | ): 163 | 164 | def exists(fn: str) -> bool: 165 | if fn in {"-", ""}: 166 | return False 167 | print("Exists?", fn, fn in {input_filename, output_filename}) 168 | return fn in {input_filename, output_filename} 169 | 170 | monkeypatch.setattr(os.path, "exists", exists) 171 | inp, outp = pytmc.bin.template.split_input_output(argument) 172 | assert inp == input_filename 173 | assert outp == output_filename 174 | -------------------------------------------------------------------------------- /pytmc/tests/test_integrations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collections of higher level tests that don't fit cleanly into unit or module 3 | test files. 4 | """ 5 | 6 | import pytest 7 | 8 | from pytmc import parser 9 | from pytmc.bin.db import process as db_process 10 | 11 | from .conftest import PROJ_ROOT, TMC_ROOT 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "tmc_file_name, target_pv", 16 | [ 17 | pytest.param(TMC_ROOT / "ArbiterPLC.tmc", "BeamClass"), 18 | pytest.param( 19 | PROJ_ROOT / "pmps-dev-arbiter/Arbiter/ArbiterPLC/ArbiterPLC.tmc", 20 | "Attenuationsssss", # sic 21 | marks=pytest.mark.xfail, 22 | ), 23 | ], 24 | ) 25 | def test_global_pinned_variables(tmc_file_name, target_pv): 26 | """ 27 | Ensure that one of the pinned global variables can be found in the list of 28 | records created when parsing this tmc file. This tmc file was specifically 29 | created to contain the 'plcAttribute_pytmc' style <Name> fields in place of 30 | the normal 'pytmc'. 31 | """ 32 | tmc = parser.parse(tmc_file_name) 33 | 34 | records, exceptions = db_process( 35 | tmc, dbd_file=None, allow_errors=False, show_error_context=True 36 | ) 37 | 38 | assert any((target_pv in x.pvname) for x in records) 39 | assert exceptions == [] 40 | 41 | 42 | def test_allow_no_pragma(): 43 | """ 44 | Test for the existence of a variable included in the records, despite it 45 | lacking a proper set of pragmas. 46 | """ 47 | tmc_file_name = TMC_ROOT / ("xtes_sxr_plc.tmc") 48 | 49 | tmc = parser.parse(tmc_file_name) 50 | 51 | records, exceptions = db_process( 52 | tmc, 53 | dbd_file=None, 54 | allow_errors=True, 55 | show_error_context=True, 56 | allow_no_pragma=False, 57 | ) 58 | 59 | all_records, exceptions = db_process( 60 | tmc, 61 | dbd_file=None, 62 | allow_errors=True, 63 | show_error_context=True, 64 | allow_no_pragma=True, 65 | ) 66 | good_records = 129 67 | total_records = 1005 68 | 69 | assert good_records == len(records) 70 | assert good_records == len(list(x.valid for x in records if x.valid)) 71 | assert total_records == len(all_records) 72 | assert good_records == len(list(x.valid for x in all_records if x.valid)) 73 | 74 | # this variable lacks a pragma 75 | target_variable = "GVL_DEVICES.MR2K3_GCC_1.rV" 76 | for x in all_records: 77 | print(x.tcname) 78 | assert any((target_variable == x.tcname) for x in all_records) 79 | assert exceptions == [] 80 | -------------------------------------------------------------------------------- /pytmc/tests/test_lint.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pytmc import parser 4 | from pytmc.bin.pragmalint import lint_source 5 | 6 | 7 | def make_pragma(text): 8 | "Make a multiline pytmc pragma" 9 | return ( 10 | """\ 11 | {attribute 'pytmc' := ' 12 | %s 13 | '}""" 14 | % text 15 | ) 16 | 17 | 18 | def make_source(pragma, variable, type): 19 | """ 20 | Make a wrapper for a pragma'd variable, to be used with `lint_source` 21 | """ 22 | 23 | source = f""" 24 | {make_pragma(pragma)} 25 | {variable} : {type}; 26 | """ 27 | 28 | class Wrapper: 29 | _source = source 30 | name = "source-wrapper" 31 | tag = "ST" 32 | 33 | def find(cls): 34 | if cls is parser.Declaration: 35 | yield Decl 36 | 37 | class Decl: 38 | parent = Wrapper 39 | text = source 40 | tag = "Decl" 41 | 42 | return Wrapper 43 | 44 | 45 | def make_source_param(pragma, variable="VAR", type="INT", **param_kw): 46 | "Make a pytest.param for use with `lint_source`" 47 | wrapper = make_source(pragma, variable, type) 48 | identifier = repr(wrapper._source) 49 | return pytest.param(wrapper, id=identifier, **param_kw) 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "source", 54 | [ 55 | make_source_param("pv: test"), 56 | make_source_param("pv: test; io: io"), 57 | make_source_param("pv: test;; io: io"), 58 | make_source_param("pv: test\r\n\r io: io"), 59 | # Valid I/O types 60 | make_source_param("pv: test; io: i"), 61 | make_source_param("pv: test; io: o"), 62 | make_source_param("pv: test; io: io"), 63 | make_source_param("pv: test; io: input"), 64 | make_source_param("pv: test; io: output"), 65 | make_source_param("pv: test; io: rw"), 66 | make_source_param("pv: test; io: ro"), 67 | # Bad IO type 68 | make_source_param("io: foobar", marks=pytest.mark.xfail), 69 | # Missing delimiters 70 | make_source_param("pv: test io: test", marks=pytest.mark.xfail), 71 | make_source_param("pv: test test", marks=pytest.mark.xfail), 72 | # No PV 73 | make_source_param("io: io", marks=pytest.mark.xfail), 74 | make_source_param("abc", marks=pytest.mark.xfail), 75 | # $ character.... 76 | make_source_param("pv: $(TEST)", marks=pytest.mark.xfail), 77 | # Update rates 78 | make_source_param("pv: test; update: 1HZ poll"), 79 | make_source_param("pv: test; update: 1s poll"), 80 | make_source_param("pv: test; update: 1HZ notify"), 81 | make_source_param("pv: test; update: 1s notify"), 82 | make_source_param("pv: test; update: 1HZ"), 83 | make_source_param("pv: test; update: 1s"), 84 | make_source_param("pv: test; update: 100es", marks=pytest.mark.xfail), 85 | make_source_param("pv: test; update: 1s test", marks=pytest.mark.xfail), 86 | make_source_param("pv: test; update: notify 1s", marks=pytest.mark.xfail), 87 | make_source_param("pv: test; field: SCAN I/O Intr"), 88 | make_source_param("pv: test; field: SCAN 1 second", marks=pytest.mark.xfail), 89 | # Archiver settings 90 | make_source_param("pv: test; archive: no"), 91 | make_source_param("pv: test; archive: 1s"), 92 | make_source_param("pv: test; archive: 1s"), 93 | make_source_param("pv: test; archive: 1s scan"), 94 | make_source_param("pv: test; archive: 1s monitor"), 95 | make_source_param("pv: test; archive: 1Hz scan"), 96 | make_source_param("pv: test; archive: 1Hz monitor"), 97 | make_source_param("pv: test; archive: 1Hz test", marks=pytest.mark.xfail), 98 | make_source_param("pv: test; archive: 1s test", marks=pytest.mark.xfail), 99 | make_source_param("pv: test; archive: 1Hz test", marks=pytest.mark.xfail), 100 | make_source_param("pv: test; archive: 1es", marks=pytest.mark.xfail), 101 | # Link settings 102 | make_source_param("pv: test; link: OTHER:PV"), 103 | make_source_param("pv: test; io: ro; link: OTHER:PV", marks=pytest.mark.xfail), 104 | make_source_param("pv: test; link: SPACES IN PV", marks=pytest.mark.xfail), 105 | ], 106 | ) 107 | def test_lint_pragma(source): 108 | print("Linting source:") 109 | print(source._source) 110 | for info in lint_source("filename", source, verbose=True): 111 | if info.exception: 112 | raise info.exception 113 | -------------------------------------------------------------------------------- /pytmc/tests/test_motion.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pytmc import parser 4 | from pytmc.bin import stcmd 5 | from pytmc.pragmas import get_pragma 6 | 7 | from .conftest import get_real_motor_symbols 8 | 9 | 10 | def test_motion_stcmd(capsys, project_filename): 11 | """ 12 | Sanity check of motor setup in the st.cmd files 13 | 14 | For all plc projects: 15 | 1. Is a controller created only when needed? 16 | 2. Are the right number of axes created? 17 | 18 | Note: capsys is a built-in pytest fixture for capturing stdout and stderr 19 | """ 20 | controller_func = "EthercatMCCreateController" 21 | motor_func = "EthercatMCCreateAxis" 22 | 23 | full_project = parser.parse(project_filename) 24 | 25 | for plc_name, plc_project in full_project.plcs_by_name.items(): 26 | motors = get_real_motor_symbols(plc_project) 27 | # Clear captured buffer just in case 28 | capsys.readouterr() 29 | stcmd.main( 30 | project_filename, plc_name=plc_name, only_motor=True, allow_errors=True 31 | ) 32 | output = capsys.readouterr().out 33 | 34 | if motors: 35 | assert controller_func in output 36 | else: 37 | assert controller_func not in output 38 | 39 | assert output.count(motor_func) == len(motors) 40 | 41 | 42 | def test_axis_name_without_pragma(): 43 | from .test_xml_collector import make_mock_twincatitem, make_mock_type 44 | 45 | axis = make_mock_twincatitem( 46 | name="Main.my_axis", 47 | data_type=make_mock_type("ST_MotionStage", is_complex_type=True), 48 | # pragma='pv: OUTER', 49 | ) 50 | 51 | class NCAxis: 52 | name = "Axis 1" 53 | 54 | axis.nc_axis = NCAxis 55 | user_config = dict(delim=":", prefix="PREFIX") 56 | prefix, name = stcmd.get_name(axis, user_config=user_config) 57 | assert name == NCAxis.name.replace(" ", user_config["delim"]) 58 | assert prefix == user_config["prefix"] + user_config["delim"] 59 | 60 | 61 | def test_axis_name_with_pragma(): 62 | from .test_xml_collector import make_mock_twincatitem, make_mock_type 63 | 64 | axis = make_mock_twincatitem( 65 | name="Main.my_axis", 66 | data_type=make_mock_type("ST_MotionStage", is_complex_type=True), 67 | pragma="pv: MY:STAGE", 68 | ) 69 | 70 | # axis.nc_axis is unimportant here as we have the pragma 71 | user_config = dict(delim=":", prefix="PREFIX") 72 | prefix, name = stcmd.get_name(axis, user_config=user_config) 73 | assert (prefix, name) == ("MY:", "STAGE") 74 | 75 | 76 | def test_mixed_motionstage_naming(): 77 | """ 78 | Check an example tmc file with 9 ST_MotionStage and 1 DUT_MotionStage 79 | 80 | We are expecting 10 motor symbols 81 | If only 9: we only recognize ST_MotionStage 82 | If only 1: we only recognize DUT_MotionStage 83 | """ 84 | file = Path(__file__).parent / 'tmc_files' / 'tc_mot_example.tmc' 85 | tmc_item = parser.parse(file) 86 | motors = tmc_item.find(parser.Symbol_ST_MotionStage) 87 | assert len(list(motors)) == 10 88 | 89 | 90 | def test_macro_in_motor_stcmd(): 91 | """ 92 | Make sure the @ -> $ substitutions happen in stcmd. 93 | 94 | The example TMC has some @(PREFIX) substitutions on motors as well as some 95 | motors with no @ substitutions. 96 | """ 97 | file = Path(__file__).parent / 'tmc_files' / 'tc_mot_example.tmc' 98 | tmc_item = parser.parse(file) 99 | all_motors = list(tmc_item.find(parser.Symbol_ST_MotionStage)) 100 | yes_sub = [ 101 | motor for motor in all_motors if "@" in next(get_pragma(motor)) 102 | ] 103 | assert len(yes_sub) > 0, "No motors found that need subs" 104 | assert len(all_motors) > len(yes_sub), "No motors found that don't need subs" 105 | user_config = dict(delim=":", prefix="") 106 | for motor in all_motors: 107 | prefix, name = stcmd.get_name(obj=motor, user_config=user_config) 108 | if motor in yes_sub: 109 | # All the subs here are supposed to drop in the PREFIX 110 | assert "$(PREFIX)" in prefix 111 | else: 112 | # Make sure the non-subs don't get distorted 113 | assert prefix + name in next(get_pragma(motor)) 114 | # Make sure we didn't miscategorize a yes_sub 115 | assert "$(PREFIX)" not in prefix + name 116 | assert "@" not in prefix 117 | assert "@" not in name 118 | -------------------------------------------------------------------------------- /pytmc/tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from pytmc.parser import get_pou_call_blocks, parse, variables_from_declaration 6 | 7 | from .conftest import TEST_PATH 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "decl, expected", 12 | [ 13 | pytest.param( 14 | """ 15 | PROGRAM Main 16 | VAR 17 | M1: FB_DriveVirtual; 18 | M1Link: FB_NcAxis; 19 | bLimitFwdM1 AT %I*: BOOL; 20 | bLimitBwdM1 AT %I*: BOOL; 21 | END_VAR 22 | """, 23 | { 24 | "M1": {"spec": "", "type": "FB_DriveVirtual"}, 25 | "M1Link": {"spec": "", "type": "FB_NcAxis"}, 26 | "bLimitFwdM1": {"spec": "%I*", "type": "BOOL"}, 27 | "bLimitBwdM1": {"spec": "%I*", "type": "BOOL"}, 28 | }, 29 | id="prog1", 30 | ), 31 | pytest.param( 32 | """ 33 | PROGRAM Main 34 | VAR 35 | M1, M2: FB_DriveVirtual; 36 | M1Link: FB_NcAxis; 37 | bLimitFwdM1 AT %I*: BOOL; 38 | bLimitBwdM1, Foobar AT %I*: BOOL; 39 | END_VAR 40 | """, 41 | { 42 | "M1": {"spec": "", "type": "FB_DriveVirtual"}, 43 | "M2": {"spec": "", "type": "FB_DriveVirtual"}, 44 | "M1Link": {"spec": "", "type": "FB_NcAxis"}, 45 | "bLimitFwdM1": {"spec": "%I*", "type": "BOOL"}, 46 | "bLimitBwdM1": {"spec": "%I*", "type": "BOOL"}, 47 | "Foobar": {"spec": "%I*", "type": "BOOL"}, 48 | }, 49 | id="prog1_with_commas", 50 | ), 51 | pytest.param( 52 | """ 53 | PROGRAM Main 54 | VAR 55 | engine AT %QX0.0: BOOL; 56 | deviceUp AT %QX0.1: BOOL; 57 | deviceDown AT %QX0.2: BOOL; 58 | timerUp: TON; 59 | timerDown: TON; 60 | steps: BYTE; 61 | count: UINT := 0; 62 | devSpeed: TIME := t#10ms; 63 | devTimer: TP; 64 | switch: BOOL; 65 | END_VAR 66 | """, 67 | { 68 | "engine": {"spec": "%QX0.0", "type": "BOOL"}, 69 | "deviceUp": {"spec": "%QX0.1", "type": "BOOL"}, 70 | "deviceDown": {"spec": "%QX0.2", "type": "BOOL"}, 71 | "timerUp": {"spec": "", "type": "TON"}, 72 | "timerDown": {"spec": "", "type": "TON"}, 73 | "steps": {"spec": "", "type": "BYTE"}, 74 | "count": {"spec": "", "type": "UINT", "value": "0"}, 75 | "devSpeed": {"spec": "", "type": "TIME", "value": "t#10ms"}, 76 | "devTimer": {"spec": "", "type": "TP"}, 77 | "switch": {"spec": "", "type": "BOOL"}, 78 | }, 79 | id="berkoff_xtreme_vars", 80 | ), 81 | pytest.param( 82 | """ 83 | PROGRAM Main 84 | VAR 85 | arr1 at %I*: ARRAY [1..5] OF INT := 1,2,3,4,5; 86 | END_VAR 87 | """, 88 | { 89 | "arr1": { 90 | "spec": "%I*", 91 | "type": "ARRAY [1..5] OF INT", 92 | "value": "1,2,3,4,5", 93 | }, 94 | }, 95 | id="int_array", 96 | ), 97 | pytest.param( 98 | """ 99 | PROGRAM Main 100 | VAR 101 | TYPE STRUCT1: 102 | STRUCT 103 | p1:int; 104 | p2:int; 105 | p3:dword; 106 | END_STRUCT 107 | END_TYPE 108 | arr1 : ARRAY[1..3] OF STRUCT1:= [(p1:=1,p2:=10,p3:=4723), (p1:=2,p2:=0,p3:=299), (p1:=14,p2:=5,p3:=112)]; 109 | END_VAR 110 | """, # noqa: E501 111 | { 112 | "arr1": { 113 | "spec": "", 114 | "type": "ARRAY[1..3] OF STRUCT1", 115 | "value": "[(p1:=1,p2:=10,p3:=4723), (p1:=2,p2:=0,p3:=299), (p1:=14,p2:=5,p3:=112)]", # noqa: E501 116 | }, 117 | }, 118 | id="structs", 119 | ), 120 | pytest.param( 121 | """ 122 | PROGRAM Main 123 | VAR 124 | TYPE STRUCT1 : 125 | STRUCT 126 | p1:int; 127 | p2:int; 128 | p3:dword; 129 | END_STRUCT 130 | END_TYPE 131 | arr1 : ARRAY[1..3] OF STRUCT1:= [(p1:=1,p2:=10,p3:=4723), 132 | (p1:=2,p2:=0,p3:=299), 133 | (p1:=14,p2:=5,p3:=112)]; 134 | END_VAR 135 | """, 136 | { 137 | "arr1": { 138 | "spec": "", 139 | "type": "ARRAY[1..3] OF STRUCT1", 140 | "value": "[(p1:=1,p2:=10,p3:=4723), (p1:=2,p2:=0,p3:=299), (p1:=14,p2:=5,p3:=112)]", # noqa: E501 141 | }, 142 | }, 143 | id="multiline_structs", 144 | marks=pytest.mark.xfail, 145 | ), 146 | ], 147 | ) 148 | def test_variables_from_declaration(decl, expected): 149 | assert variables_from_declaration(decl) == expected 150 | 151 | 152 | def test_call_blocks(): 153 | decl = """ 154 | PROGRAM Main 155 | VAR 156 | M1: FB_DriveVirtual; 157 | M1Link: FB_NcAxis; 158 | bLimitFwdM1 AT %I*: BOOL; 159 | bLimitBwdM1 AT %I*: BOOL; 160 | 161 | END_VAR 162 | """ 163 | 164 | impl = """ 165 | M1Link(En := TRUE); 166 | M1(En := TRUE, 167 | bEnable := TRUE, 168 | bLimitFwd := bLimitFwdM1, 169 | bLimitBwd := bLimitBwdM1, 170 | Axis := M1Link.axis); 171 | 172 | M1(En := FALSE); 173 | """ 174 | 175 | assert get_pou_call_blocks(decl, impl) == { 176 | "M1": { 177 | "En": "FALSE", 178 | "bEnable": "TRUE", 179 | "bLimitFwd": "bLimitFwdM1", 180 | "bLimitBwd": "bLimitBwdM1", 181 | "Axis": "M1Link.axis", 182 | }, 183 | "M1Link": {"En": "TRUE"}, 184 | } 185 | 186 | 187 | def test_route_parsing(): 188 | # located in: C:\twincat\3.1\StaticRoutes.xml 189 | routes = parse(TEST_PATH / "static_routes.xml") 190 | remote_connections = routes.RemoteConnections[0] 191 | assert remote_connections.by_name == { 192 | "LAMP-VACUUM": { 193 | "Name": "LAMP-VACUUM", 194 | "Address": "172.21.37.140", 195 | "NetId": "5.21.50.18.1.1", 196 | "Type": "TCP_IP", 197 | }, 198 | "AMO-BASE": { 199 | "Name": "AMO-BASE", 200 | "Address": "172.21.37.114", 201 | "NetId": "5.17.65.196.1.1", 202 | "Type": "TCP_IP", 203 | }, 204 | } 205 | 206 | assert remote_connections.by_address == { 207 | "172.21.37.114": { 208 | "Address": "172.21.37.114", 209 | "Name": "AMO-BASE", 210 | "NetId": "5.17.65.196.1.1", 211 | "Type": "TCP_IP", 212 | }, 213 | "172.21.37.140": { 214 | "Address": "172.21.37.140", 215 | "Name": "LAMP-VACUUM", 216 | "NetId": "5.21.50.18.1.1", 217 | "Type": "TCP_IP", 218 | }, 219 | } 220 | 221 | assert remote_connections.by_ams_id == { 222 | "5.17.65.196.1.1": { 223 | "Address": "172.21.37.114", 224 | "Name": "AMO-BASE", 225 | "NetId": "5.17.65.196.1.1", 226 | "Type": "TCP_IP", 227 | }, 228 | "5.21.50.18.1.1": { 229 | "Address": "172.21.37.140", 230 | "Name": "LAMP-VACUUM", 231 | "NetId": "5.21.50.18.1.1", 232 | "Type": "TCP_IP", 233 | }, 234 | } 235 | 236 | 237 | def test_type_alias_parsing(): 238 | """ 239 | Type aliases should resolve to the same walk as their source 240 | """ 241 | file = Path(__file__).parent / 'tmc_files' / 'tc_mot_example.tmc' 242 | tmc_item = parse(file) 243 | dut_mot = None 244 | st_mot = None 245 | for dtyp in tmc_item.DataTypes[0].DataType: 246 | if dtyp.name == 'DUT_MotionStage': 247 | dut_mot = dtyp 248 | elif dtyp.name == 'ST_MotionStage': 249 | st_mot = dtyp 250 | assert dut_mot is not None, "Did not find DUT_MotionStage in test setup" 251 | assert st_mot is not None, "Did not find ST_MotionStage in test setup" 252 | assert list(dut_mot.walk()) == list(st_mot.walk()) 253 | -------------------------------------------------------------------------------- /pytmc/tests/test_project.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | import pytest 4 | 5 | from pytmc import parser 6 | 7 | from .conftest import get_real_motor_symbols 8 | 9 | 10 | def test_load_and_repr(project): 11 | info = repr(project) 12 | print(info[:1000], "...") 13 | 14 | 15 | def test_summarize(project): 16 | assert project.root is project 17 | for cls in [parser.Axis, parser.Encoder]: 18 | for inst in project.find(cls): 19 | print(inst.path) 20 | print("-----------------") 21 | pprint.pprint(dict(inst.summarize())) 22 | 23 | for inst in project.find(parser.Symbol): 24 | pprint.pprint(inst.info) 25 | inst.plc 26 | 27 | 28 | def test_module_ads_port(project): 29 | for inst in project.find(parser.Module): 30 | assert inst.ads_port == 851 or inst.ads_port == 852 # probably! 31 | 32 | 33 | @pytest.mark.xfail(reason="TODO / project") 34 | def test_smoke_ams_id(project): 35 | print(project.ams_id) 36 | print(project.target_ip) 37 | 38 | 39 | def test_fb_motionstage_linking(project): 40 | for inst in get_real_motor_symbols(project): 41 | pprint.pprint(inst) 42 | print("Program name", inst.program_name) 43 | print("Motor name", inst.motor_name) 44 | print("NC to PLC link", inst.nc_to_plc_link) 45 | 46 | nc_axis = inst.nc_axis 47 | print("Short NC axis name", nc_axis.name) 48 | print("NC axis", nc_axis) 49 | -------------------------------------------------------------------------------- /pytmc/tests/test_record.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from pytmc.linter import lint_db 4 | from pytmc.record import EPICSRecord, sort_fields 5 | 6 | 7 | def test_epics_record_render(): 8 | kwargs = { 9 | "pvname": "Tst:pv", 10 | "record_type": "ai", 11 | "fields": {"ZNAM": "Out", "ONAM": "In"}, 12 | "direction": "input", 13 | } 14 | 15 | ec = EPICSRecord(**kwargs) 16 | record = ec.render() 17 | print(record) # For debug purposes 18 | assert kwargs["pvname"] in record 19 | assert kwargs["record_type"] in record 20 | for key, value in kwargs["fields"].items(): 21 | assert key in record 22 | assert value in record 23 | 24 | 25 | def test_epics_record_with_linter(dbd_file): 26 | kwargs = { 27 | "pvname": "Tst:pv", 28 | "record_type": "bi", 29 | "fields": {"ZNAM": '"Out"', "ONAM": '"In"', "DTYP": '"Raw Soft Channel"'}, 30 | "direction": "input", 31 | } 32 | ec = EPICSRecord(**kwargs) 33 | record = ec.render() 34 | linted = lint_db(dbd=dbd_file, db=record) 35 | assert not (linted.errors) 36 | 37 | 38 | def test_input_record_without_write_access(): 39 | kwargs = { 40 | "pvname": "Tst:pv", 41 | "record_type": "ai", 42 | "direction": "input", 43 | } 44 | 45 | ec = EPICSRecord(**kwargs) 46 | record = ec.render() 47 | assert "ASG" in record 48 | assert "NO_WRITE" in record 49 | 50 | 51 | def test_output_record_with_write_access(): 52 | kwargs = { 53 | "pvname": "Tst:pv", 54 | "record_type": "ao", 55 | "direction": "output", 56 | } 57 | ec = EPICSRecord(**kwargs) 58 | record = ec.render() 59 | assert "ASG" not in record 60 | 61 | 62 | def test_sort_fields(): 63 | unsorted_entry = OrderedDict( 64 | [ 65 | ("CALC", None), 66 | ("very_fake", None), 67 | ("ONVL", None), 68 | ("FTVL", None), 69 | ("not_real", None), 70 | ("NAME", None), 71 | ("SVSV", None), 72 | ("ONSV", None), 73 | ] 74 | ) 75 | correct_entry = OrderedDict( 76 | [ 77 | ("NAME", None), 78 | ("ONVL", None), 79 | ("FTVL", None), 80 | ("ONSV", None), 81 | ("SVSV", None), 82 | ("CALC", None), 83 | ("not_real", None), 84 | ("very_fake", None), 85 | ] 86 | ) 87 | output = sort_fields(unsorted_entry) 88 | assert output == correct_entry 89 | -------------------------------------------------------------------------------- /pytmc/tests/test_xml_obj.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from pytmc.pragmas import separate_configs_by_pv, split_pytmc_pragma 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | @pytest.fixture() 11 | def leaf_bool_pragma_string(): 12 | return """ 13 | pv: TEST:MAIN:NEW_VAR_OUT 14 | type: bo 15 | field: ZNAM SINGLE 16 | field: ONAM MULTI 17 | field: SCAN 1 second 18 | str: %d 19 | io: o 20 | init: True 21 | pv: TEST:MAIN:NEW_VAR_IN 22 | type:bi 23 | field: ZNAM SINGLE 24 | field: ONAM MULTI 25 | field: SCAN 1 second 26 | str: %d 27 | io: i 28 | """ 29 | 30 | 31 | @pytest.fixture() 32 | def leaf_bool_pragma_string_w_semicolon(leaf_bool_pragma_string): 33 | return ( 34 | leaf_bool_pragma_string 35 | + """ 36 | ensure: that ; semicolons: work; 37 | """ 38 | ) 39 | 40 | 41 | @pytest.fixture() 42 | def leaf_bool_pragma_string_single_line(): 43 | return """pv:pv_name""" 44 | 45 | 46 | @pytest.fixture() 47 | def light_leaf_bool_pragma_string(): 48 | return """ 49 | pv: TEST:MAIN:NEW_VAR_OUT 50 | io: o 51 | pv: TEST:MAIN:NEW_VAR_IN 52 | io: i 53 | pv: TEST:MAIN:NEW_VAR_IO 54 | io: io 55 | pv: TEST:MAIN:NEW_VAR_SIMPLE 56 | """ 57 | 58 | 59 | @pytest.fixture(scope="function") 60 | def branch_bool_pragma_string(): 61 | return """ 62 | pv: FIRST 63 | pv: SECOND 64 | """ 65 | 66 | 67 | @pytest.fixture(scope="function") 68 | def branch_bool_pragma_string_empty(branch_bool_pragma_string): 69 | return ( 70 | branch_bool_pragma_string 71 | + """ 72 | pv: 73 | pv:""" 74 | ) 75 | 76 | 77 | @pytest.fixture(scope="function") 78 | def branch_connection_pragma_string(): 79 | return """ 80 | pv: MIDDLE 81 | aux: nothing 82 | """ 83 | 84 | 85 | @pytest.fixture(scope="function") 86 | def empty_pv_pragma_string(): 87 | return """ 88 | pv: 89 | """ 90 | 91 | 92 | @pytest.fixture(scope="function") 93 | def branch_skip_pragma_string(): 94 | return """ 95 | skip: 96 | """ 97 | 98 | 99 | @pytest.mark.parametrize( 100 | "model_set", 101 | [ 102 | (0), 103 | (1), 104 | ], 105 | ) 106 | def test_config_lines( 107 | leaf_bool_pragma_string_w_semicolon, leaf_bool_pragma_string_single_line, model_set 108 | ): 109 | if model_set == 0: 110 | string = leaf_bool_pragma_string_w_semicolon 111 | test = [ 112 | {"title": "pv", "tag": "TEST:MAIN:NEW_VAR_OUT"}, 113 | {"title": "type", "tag": "bo"}, 114 | {"title": "field", "tag": dict(f_name="ZNAM", f_set="SINGLE")}, 115 | {"title": "field", "tag": dict(f_name="ONAM", f_set="MULTI")}, 116 | {"title": "field", "tag": dict(f_name="SCAN", f_set="1 second")}, 117 | {"title": "str", "tag": "%d"}, 118 | {"title": "io", "tag": "o"}, 119 | {"title": "init", "tag": "True"}, 120 | {"title": "pv", "tag": "TEST:MAIN:NEW_VAR_IN"}, 121 | {"title": "type", "tag": "bi"}, 122 | {"title": "field", "tag": dict(f_name="ZNAM", f_set="SINGLE")}, 123 | {"title": "field", "tag": dict(f_name="ONAM", f_set="MULTI")}, 124 | {"title": "field", "tag": dict(f_name="SCAN", f_set="1 second")}, 125 | {"title": "str", "tag": "%d"}, 126 | {"title": "io", "tag": "i"}, 127 | {"title": "ensure", "tag": "that"}, 128 | {"title": "semicolons", "tag": "work"}, 129 | ] 130 | if model_set == 1: 131 | string = leaf_bool_pragma_string_single_line 132 | test = [{"title": "pv", "tag": "pv_name"}] 133 | 134 | assert split_pytmc_pragma(string) == test 135 | 136 | 137 | def test_neaten_field(leaf_bool_pragma_string): 138 | config_lines = split_pytmc_pragma(leaf_bool_pragma_string) 139 | assert config_lines[2]["tag"] == {"f_name": "ZNAM", "f_set": "SINGLE"} 140 | 141 | 142 | def test_formatted_config_lines(leaf_bool_pragma_string): 143 | config_lines = split_pytmc_pragma(leaf_bool_pragma_string) 144 | assert config_lines == [ 145 | {"title": "pv", "tag": "TEST:MAIN:NEW_VAR_OUT"}, 146 | {"title": "type", "tag": "bo"}, 147 | {"title": "field", "tag": {"f_name": "ZNAM", "f_set": "SINGLE"}}, 148 | {"title": "field", "tag": {"f_name": "ONAM", "f_set": "MULTI"}}, 149 | {"title": "field", "tag": {"f_name": "SCAN", "f_set": "1 second"}}, 150 | {"title": "str", "tag": "%d"}, 151 | {"title": "io", "tag": "o"}, 152 | {"title": "init", "tag": "True"}, 153 | {"title": "pv", "tag": "TEST:MAIN:NEW_VAR_IN"}, 154 | {"title": "type", "tag": "bi"}, 155 | {"title": "field", "tag": {"f_name": "ZNAM", "f_set": "SINGLE"}}, 156 | {"title": "field", "tag": {"f_name": "ONAM", "f_set": "MULTI"}}, 157 | {"title": "field", "tag": {"f_name": "SCAN", "f_set": "1 second"}}, 158 | {"title": "str", "tag": "%d"}, 159 | {"title": "io", "tag": "i"}, 160 | ] 161 | 162 | 163 | def test_config_by_name(leaf_bool_pragma_string): 164 | config_lines = split_pytmc_pragma(leaf_bool_pragma_string) 165 | configs = dict(separate_configs_by_pv(config_lines)) 166 | assert configs == { 167 | "TEST:MAIN:NEW_VAR_OUT": [ 168 | {"title": "pv", "tag": "TEST:MAIN:NEW_VAR_OUT"}, 169 | {"title": "type", "tag": "bo"}, 170 | {"title": "field", "tag": {"f_name": "ZNAM", "f_set": "SINGLE"}}, 171 | {"title": "field", "tag": {"f_name": "ONAM", "f_set": "MULTI"}}, 172 | {"title": "field", "tag": {"f_name": "SCAN", "f_set": "1 second"}}, 173 | {"title": "str", "tag": "%d"}, 174 | {"title": "io", "tag": "o"}, 175 | {"title": "init", "tag": "True"}, 176 | ], 177 | "TEST:MAIN:NEW_VAR_IN": [ 178 | {"title": "pv", "tag": "TEST:MAIN:NEW_VAR_IN"}, 179 | {"title": "type", "tag": "bi"}, 180 | {"title": "field", "tag": {"f_name": "ZNAM", "f_set": "SINGLE"}}, 181 | {"title": "field", "tag": {"f_name": "ONAM", "f_set": "MULTI"}}, 182 | {"title": "field", "tag": {"f_name": "SCAN", "f_set": "1 second"}}, 183 | {"title": "str", "tag": "%d"}, 184 | {"title": "io", "tag": "i"}, 185 | ], 186 | } 187 | 188 | 189 | def test_config_names(leaf_bool_pragma_string): 190 | config_lines = split_pytmc_pragma(leaf_bool_pragma_string) 191 | configs = dict(separate_configs_by_pv(config_lines)) 192 | assert set(configs) == {"TEST:MAIN:NEW_VAR_OUT", "TEST:MAIN:NEW_VAR_IN"} 193 | 194 | 195 | def test_fix_to_config_name(leaf_bool_pragma_string): 196 | config_lines = split_pytmc_pragma(leaf_bool_pragma_string) 197 | configs = dict(separate_configs_by_pv(config_lines)) 198 | assert configs["TEST:MAIN:NEW_VAR_OUT"] == [ 199 | {"title": "pv", "tag": "TEST:MAIN:NEW_VAR_OUT"}, 200 | {"title": "type", "tag": "bo"}, 201 | {"title": "field", "tag": {"f_name": "ZNAM", "f_set": "SINGLE"}}, 202 | {"title": "field", "tag": {"f_name": "ONAM", "f_set": "MULTI"}}, 203 | {"title": "field", "tag": {"f_name": "SCAN", "f_set": "1 second"}}, 204 | {"title": "str", "tag": "%d"}, 205 | {"title": "io", "tag": "o"}, 206 | {"title": "init", "tag": "True"}, 207 | ] 208 | 209 | 210 | def test_get_config_lines(leaf_bool_pragma_string): 211 | config_lines = split_pytmc_pragma(leaf_bool_pragma_string) 212 | configs = dict(separate_configs_by_pv(config_lines)) 213 | assert configs["TEST:MAIN:NEW_VAR_OUT"] == [ 214 | {"tag": "TEST:MAIN:NEW_VAR_OUT", "title": "pv"}, 215 | {"tag": "bo", "title": "type"}, 216 | {"tag": {"f_name": "ZNAM", "f_set": "SINGLE"}, "title": "field"}, 217 | {"tag": {"f_name": "ONAM", "f_set": "MULTI"}, "title": "field"}, 218 | {"tag": {"f_name": "SCAN", "f_set": "1 second"}, "title": "field"}, 219 | {"tag": "%d", "title": "str"}, 220 | {"tag": "o", "title": "io"}, 221 | {"tag": "True", "title": "init"}, 222 | ] 223 | 224 | assert configs["TEST:MAIN:NEW_VAR_IN"] == [ 225 | {"tag": "TEST:MAIN:NEW_VAR_IN", "title": "pv"}, 226 | {"tag": "bi", "title": "type"}, 227 | {"tag": {"f_name": "ZNAM", "f_set": "SINGLE"}, "title": "field"}, 228 | {"tag": {"f_name": "ONAM", "f_set": "MULTI"}, "title": "field"}, 229 | {"tag": {"f_name": "SCAN", "f_set": "1 second"}, "title": "field"}, 230 | {"tag": "%d", "title": "str"}, 231 | {"tag": "i", "title": "io"}, 232 | ] 233 | -------------------------------------------------------------------------------- /pytmc/validation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcdshub/pytmc/94c62dbb0584aba3f3e38ef14ac5186a7425e5ca/pytmc/validation/__init__.py -------------------------------------------------------------------------------- /pytmc/validation/v0.1.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pcdshub/pytmc/94c62dbb0584aba3f3e38ef14ac5186a7425e5ca/pytmc/validation/v0.1.py -------------------------------------------------------------------------------- /pytmc/version.py: -------------------------------------------------------------------------------- 1 | from collections import UserString 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | 6 | class VersionProxy(UserString): 7 | """ 8 | Version handling helper that pairs with setuptools-scm. 9 | 10 | This allows for pkg.__version__ to be dynamically retrieved on request by 11 | way of setuptools-scm. 12 | 13 | This deferred evaluation of the version until it is checked saves time on 14 | package import. 15 | 16 | This supports the following scenarios: 17 | 18 | 1. A git checkout (.git exists) 19 | 2. A git archive / a tarball release from GitHub that includes version 20 | information in .git_archival.txt. 21 | 3. An existing _version.py generated by setuptools_scm 22 | 4. A fallback in case none of the above match - resulting in a version of 23 | 0.0.unknown 24 | """ 25 | 26 | def __init__(self): 27 | self._version = None 28 | 29 | def _get_version(self) -> Optional[str]: 30 | # Checking for directory is faster than failing out of get_version 31 | here = Path(__file__).resolve() 32 | repo_root = here.parent.parent 33 | if (repo_root / ".git").exists() or (repo_root / ".git_archival.txt").exists(): 34 | try: 35 | # Git checkout 36 | from setuptools_scm import get_version 37 | 38 | return get_version(root="..", relative_to=here) 39 | except (ImportError, LookupError): 40 | ... 41 | 42 | # Check this second because it can exist in a git repo if we've 43 | # done a build at least once. 44 | try: 45 | from ._version import version # noqa: F401 46 | 47 | return version 48 | except ImportError: 49 | ... 50 | 51 | return None 52 | 53 | @property 54 | def data(self) -> str: 55 | # This is accessed by UserString to allow us to lazily fill in the 56 | # information 57 | if self._version is None: 58 | self._version = self._get_version() or "0.0.unknown" 59 | 60 | return self._version 61 | 62 | 63 | __version__ = version = VersionProxy() 64 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jinja2 2 | lxml 3 | epics-pypdb>=0.1.5 4 | --------------------------------------------------------------------------------