├── src └── tendo │ ├── py.typed │ ├── tests │ ├── __init__.py │ ├── py.typed │ ├── assets │ │ ├── sample_utf8.txt │ │ ├── utf8.txt │ │ ├── utf8-after-append.txt │ │ ├── sample_utf8_bom.txt │ │ ├── full_sample_utf8_bom.txt │ │ ├── utf8-invalid.txt │ │ ├── sample_ucs2_be.txt │ │ └── sample_ucs2_le.txt │ ├── test_execfile2.py │ ├── test_tee.py │ ├── test_unicode.py │ └── test_singleton.py │ ├── __init__.py │ ├── execfile2.py │ ├── singleton.py │ ├── unicode.py │ └── tee.py ├── .config ├── requirements.in ├── requirements-docs.in ├── requirements-test.in └── constraints.txt ├── docs └── index.md ├── .github ├── FUNDING.yml ├── release-drafter.yml ├── dependabot.yml ├── workflows │ ├── tox.yml │ └── release.yml └── labels.yml ├── hooks └── pre-commit ├── codecov.yml ├── .yamllint ├── .taplo.toml ├── .readthedocs.yml ├── .gitignore ├── README.md ├── pytest.ini ├── LICENSE.txt ├── mkdocs.yml ├── .pre-commit-config.yaml ├── tox.ini └── pyproject.toml /src/tendo/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.config/requirements.in: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /src/tendo/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tendo/tests/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tendo/tests/assets/sample_utf8.txt: -------------------------------------------------------------------------------- 1 | țăpusă -------------------------------------------------------------------------------- /src/tendo/tests/assets/utf8.txt: -------------------------------------------------------------------------------- 1 | This is a test -------------------------------------------------------------------------------- /.config/requirements-docs.in: -------------------------------------------------------------------------------- 1 | mkdocs-ansible>=24.3.1 2 | -------------------------------------------------------------------------------- /src/tendo/tests/assets/utf8-after-append.txt: -------------------------------------------------------------------------------- 1 | This is a testabcșț_ṩṩṩ_бдж_αβώ_وت_אסל_永𪚥麵𠀀 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ssbarnea 4 | -------------------------------------------------------------------------------- /src/tendo/tests/assets/sample_utf8_bom.txt: -------------------------------------------------------------------------------- 1 | aăâ sș aă #1 2 | aăâ sș aă #2 3 | aăâ sș aă #3 4 | -------------------------------------------------------------------------------- /src/tendo/tests/assets/full_sample_utf8_bom.txt: -------------------------------------------------------------------------------- 1 | NFD aaâaa 2 | NFC aaâaa 3 | NFKD aaâaa 4 | NFKC aaâaa -------------------------------------------------------------------------------- /hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | python -m autopep8 --in-place jira/*.py setup.py tests/*.py examples/*.py 4 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # see https://github.com/ansible-community/devtools 3 | _extends: ansible-community/devtools 4 | -------------------------------------------------------------------------------- /src/tendo/tests/assets/utf8-invalid.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pycontribs/tendo/HEAD/src/tendo/tests/assets/utf8-invalid.txt -------------------------------------------------------------------------------- /src/tendo/tests/assets/sample_ucs2_be.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pycontribs/tendo/HEAD/src/tendo/tests/assets/sample_ucs2_be.txt -------------------------------------------------------------------------------- /src/tendo/tests/assets/sample_ucs2_le.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pycontribs/tendo/HEAD/src/tendo/tests/assets/sample_ucs2_le.txt -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | codecov: 3 | require_ci_to_pass: true 4 | comment: false 5 | coverage: 6 | status: 7 | patch: false 8 | project: 9 | default: 10 | threshold: 0.5% 11 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | rules: 4 | line-length: 5 | max: 999 6 | allow-non-breakable-words: true 7 | allow-non-breakable-inline-mappings: true 8 | key-duplicates: enable 9 | ignore: | 10 | .tox 11 | -------------------------------------------------------------------------------- /.config/requirements-test.in: -------------------------------------------------------------------------------- 1 | coverage[toml]>=6.5.0 2 | coveralls~=3.3.1 3 | pre-commit>=3.3.3 4 | pip 5 | pytest-cache~=1.0 6 | pytest-cov~=3.0.0 7 | pytest-html~=3.1.1 8 | pytest-instafail~=0.4.2 9 | pytest-xdist~=2.5.0 10 | pytest~=7.1.3 11 | wheel~=0.37.1 12 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | [formatting] 2 | # cspell: disable-next-line 3 | # compatibility between toml-sort-fix pre-commit hook and panekj.even-betterer-toml extension 4 | align_comments = false 5 | array_trailing_comma = false 6 | compact_arrays = true 7 | compact_entries = false 8 | compact_inline_tables = true 9 | inline_table_expand = false 10 | reorder_keys = true 11 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | 5 | mkdocs: 6 | fail_on_warning: true 7 | configuration: mkdocs.yml 8 | 9 | build: 10 | os: ubuntu-24.04 11 | tools: 12 | python: "3.11" 13 | commands: 14 | - pip install --user tox 15 | - python3 -m tox -e docs 16 | python: 17 | install: 18 | - method: pip 19 | path: tox 20 | - method: pip 21 | path: . 22 | extra_requirements: 23 | - docs 24 | submodules: 25 | include: all 26 | recursive: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | .tox 4 | .idea/workspace.xml 5 | .DS_Store 6 | .coverage 7 | coverage.xml 8 | projectFileBackup 9 | .cache/ 10 | build/*.* 11 | build 12 | docs/_build/ 13 | docs/build/*.* 14 | dist/*.* 15 | MANIFEST 16 | tendo/build/*.* 17 | tendo.egg-info 18 | log.txt 19 | 20 | 21 | 22 | pytestdebug.log 23 | 24 | test-distribute.sh 25 | .idea/misc.xml 26 | .idea/tendo.iml 27 | /ChangeLog 28 | /AUTHORS 29 | /.pytest_cache 30 | venv/* 31 | src/tendo/_version.py 32 | coverage.lcov 33 | site 34 | _readthedocs 35 | -------------------------------------------------------------------------------- /src/tendo/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Licensed to PSF under a Contributor Agreement. 3 | # See http://www.python.org/psf/license for licensing details. 4 | import sys 5 | 6 | from ._version import __version__ 7 | 8 | __author__ = "Sorin Sbarnea" 9 | __copyright__ = "Copyright 2010-2024, Sorin Sbarnea" 10 | __email__ = "sorin.sbarnea@gmail.com" 11 | __status__ = "Production" 12 | __all__ = ( 13 | "__version__", 14 | "execfile2", 15 | "singleton", 16 | "tee", 17 | "unicode", 18 | ) 19 | 20 | 21 | if sys.hexversion < 0x030A0000: 22 | sys.exit("Python 3.10 or newer is required by tendo module.") 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | version: 2 4 | updates: 5 | - package-ecosystem: pip 6 | directory: /.config/ 7 | schedule: 8 | day: sunday 9 | interval: monthly 10 | labels: 11 | - dependencies 12 | - skip-changelog 13 | versioning-strategy: lockfile-only 14 | open-pull-requests-limit: 3 15 | groups: 16 | dependencies: 17 | patterns: 18 | - "*" 19 | - package-ecosystem: "github-actions" 20 | directory: "/" 21 | schedule: 22 | interval: weekly 23 | open-pull-requests-limit: 3 24 | labels: 25 | - dependencies 26 | - skip-changelog 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://img.shields.io/pypi/v/tendo.svg?colorB=green)](https://pypi.python.org/pypi/tendo/) 2 | [![codecov](https://codecov.io/gh/pycontribs/tendo/graph/badge.svg?token=1VvPGtNT0c)](https://codecov.io/gh/pycontribs/tendo) 3 | [![](https://readthedocs.org/projects/tendo/badge/?version=latest)](http://tendo.readthedocs.io) 4 | 5 | # tendo 6 | 7 | [Tendo](https://tendo.readthedocs.io) is a python module that adds basic functionality that is 8 | not provided by Python. 9 | 10 | - [transparent Unicode support for text file operations (BOM detection)](https://tendo.readthedocs.io/#module-tendo.singleton) 11 | - python tee implementation 12 | - [improved execfile](https://tendo.readthedocs.io/#module-tendo.execfile2) 13 | 14 | ## Requirements and Compatibility 15 | 16 | - POSIX-compatible operating system, including Linux and macOS 17 | - python 3.10 or newer 18 | - tox for running tests 19 | 20 | ## Related Projects and Packages 21 | 22 | - jaraco - http://pypi.python.org/pypi/jaraco.util 23 | - pexpect (maybe) 24 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: tox 3 | 4 | on: 5 | merge_group: 6 | branches: ["main", "devel/*"] 7 | push: 8 | branches: 9 | - "main" 10 | - "releases/**" 11 | - "stable/**" 12 | pull_request: 13 | branches: 14 | - "main" 15 | pull_request_target: 16 | types: [opened, labeled, unlabeled] # for ack job 17 | workflow_call: 18 | 19 | permissions: # needed for tox.yml workflow 20 | checks: write 21 | contents: write # needed to update release 22 | id-token: write 23 | packages: write 24 | pull-requests: write 25 | jobs: 26 | ack: 27 | # checks pull-request labels and updates the release notes on pushes 28 | if: github.event_name == 'pull_request_target' || github.event_name == 'push' 29 | uses: ansible/team-devtools/.github/workflows/push.yml@main 30 | tox: 31 | if: github.event_name != 'pull_request_target' 32 | uses: ansible/team-devtools/.github/workflows/tox.yml@main 33 | with: 34 | jobs_producing_coverage: 6 35 | other_names: | 36 | docs 37 | lint 38 | pkg 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: release 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | pypi: 10 | name: Publish to PyPI registry 11 | environment: release 12 | runs-on: ubuntu-22.04 13 | 14 | env: 15 | FORCE_COLOR: 1 16 | PY_COLORS: 1 17 | TOXENV: packaging 18 | TOX_PARALLEL_NO_SPINNER: 1 19 | 20 | steps: 21 | - name: Check out src from Git 22 | uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 # needed by setuptools-scm 25 | - name: Switch to using Python 3.10 by default 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: "3.10" 29 | - name: Install tox 30 | run: >- 31 | python3 -m 32 | pip install 33 | --user 34 | tox 35 | - name: Check out src from Git 36 | uses: actions/checkout@v3 37 | with: 38 | fetch-depth: 0 # needed by setuptools-scm 39 | - name: Build dists 40 | run: python -m tox 41 | - name: Publish to pypi.org 42 | if: >- # "create" workflows run separately from "push" & "pull_request" 43 | github.event_name == 'release' 44 | uses: pypa/gh-action-pypi-publish@release/v1 45 | with: 46 | password: ${{ secrets.pypi_password }} 47 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # Format and labels used aim to match those used by Ansible project 2 | # https://github.com/marketplace/actions/github-labeler 3 | - name: bug 4 | color: "fbca04" 5 | description: "This issue/PR relates to a bug." 6 | - name: deprecated 7 | color: "fef2c0" 8 | description: "This issue/PR relates to a deprecated module." 9 | - name: docs 10 | color: "4071a5" 11 | description: "This issue/PR relates to or includes documentation." 12 | - name: enhancement 13 | color: "ededed" 14 | description: "This issue/PR relates to a feature request." 15 | - name: feature 16 | color: "006b75" 17 | description: "This issue/PR relates to a feature request." 18 | - name: major 19 | color: "c6476b" 20 | description: "Marks an important and likely breaking change." 21 | - name: packaging 22 | color: "4071a5" 23 | description: "Packaging category" 24 | - name: performance 25 | color: "555555" 26 | description: "Relates to product or testing performance." 27 | - name: skip-changelog 28 | color: "eeeeee" 29 | description: "Can be missed from the changelog." 30 | - name: stale 31 | color: "eeeeee" 32 | description: "Not updated in long time, will be closed soon." 33 | - name: wontfix 34 | color: "eeeeee" 35 | description: "This will not be worked on" 36 | - name: test 37 | color: "0e8a16" 38 | description: "This PR relates to tests, QA, CI." 39 | -------------------------------------------------------------------------------- /src/tendo/tests/test_execfile2.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | from tendo.execfile2 import execfile2 5 | 6 | 7 | def exec_py_code(code, cmd=None): 8 | (ftmp, fname_tmp) = tempfile.mkstemp() 9 | f = open(fname_tmp, "w") # encoding not specified, should use utf-8 10 | f.write(code) 11 | f.close() 12 | exit_code = execfile2(fname_tmp, cmd=cmd, quiet=True) 13 | os.close(ftmp) 14 | os.unlink(fname_tmp) 15 | return exit_code 16 | 17 | 18 | def test_normal_execution(): 19 | exit_code = exec_py_code("") 20 | assert exit_code == 0 21 | 22 | 23 | def test_bad_code(): 24 | exit_code = exec_py_code("bleah") 25 | assert exit_code == 1 26 | 27 | 28 | def test_sys_exit_0(): 29 | exit_code = exec_py_code("import sys; sys.exit(0)") 30 | assert exit_code == 0 31 | 32 | 33 | def test_sys_exit_5(): 34 | exit_code = exec_py_code("import sys; sys.exit(5)") 35 | assert exit_code == 5 36 | 37 | 38 | def test_sys_exit_text(): 39 | exit_code = exec_py_code("import sys; sys.exit('bleah')") 40 | assert exit_code == 1 41 | 42 | 43 | def test_raised_exception(): 44 | exit_code = exec_py_code("raise Exception('bleah')") 45 | assert exit_code == 1 46 | 47 | 48 | def test_command_line(): 49 | exit_code = exec_py_code( 50 | "import sys\nif len(sys.argv)==2 and sys.argv[1]=='doh!': sys.exit(-1)", 51 | cmd="doh!", 52 | ) 53 | assert exit_code == -1 54 | -------------------------------------------------------------------------------- /src/tendo/tests/test_tee.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from tendo.tee import quote_command, system, system2 5 | 6 | 7 | def test_1(): 8 | """No CMD os.system() 9 | 10 | 1 sort /? ok ok 11 | 2 "sort" /? ok ok 12 | 3 sort "/?" ok ok 13 | 4 "sort" "/?" ok [bad] 14 | 5 ""sort /?"" ok [bad] 15 | 6 "sort /?" [bad] ok 16 | 7 "sort "/?"" [bad] ok 17 | 8 ""sort" "/?"" [bad] ok 18 | """ 19 | quotes = { 20 | "dir >nul": "dir >nul", 21 | 'cd /D "C:\\Program Files\\"': '"cd /D "C:\\Program Files\\""', 22 | 'python -c "import os" dummy': '"python -c "import os" dummy"', 23 | "sort": "sort", 24 | } 25 | 26 | # we fake the os name because we want to run the test on any platform 27 | save = os.name 28 | os.name = "nt" 29 | 30 | for key, value in quotes.items(): 31 | resulted_value = quote_command(key) 32 | assert value == resulted_value 33 | # ret = os.system(resulted_value) 34 | # if not ret==0: 35 | # print("failed") 36 | os.name = save 37 | 38 | 39 | def test_2(): 40 | assert system([sys.executable, "-V"]) == 0 41 | 42 | 43 | def test_3(): 44 | assert system2([sys.executable, "-V"])[0] == 0 45 | 46 | 47 | def test_4(): 48 | assert system([sys.executable, "-c", "print()"]) == 0 49 | -------------------------------------------------------------------------------- /src/tendo/tests/test_unicode.py: -------------------------------------------------------------------------------- 1 | import filecmp 2 | import inspect 3 | import os 4 | import shutil 5 | import tempfile 6 | 7 | import pytest 8 | 9 | from tendo.unicode import open # noqa: A004 10 | 11 | 12 | @pytest.fixture 13 | def dir(): 14 | os.chdir(os.path.dirname(os.path.abspath(__file__))) 15 | return os.path.dirname(inspect.getfile(inspect.currentframe())) 16 | 17 | 18 | def test_read_utf8(dir): 19 | mode = "r" 20 | f = open(os.path.join(dir, "assets/utf8.txt"), mode) 21 | f.readlines() 22 | f.close() 23 | assert True 24 | 25 | 26 | def test_read_invalid_utf8(dir): 27 | with pytest.raises(UnicodeDecodeError): 28 | mode = "r" 29 | f = open(os.path.join(dir, "assets/utf8-invalid.txt"), mode) 30 | f.readlines() 31 | f.close() 32 | 33 | 34 | def test_write_on_existing_utf8(dir): 35 | (ftmp, fname_tmp) = tempfile.mkstemp() 36 | shutil.copyfile(os.path.join(dir, "assets/utf8.txt"), fname_tmp) 37 | f = open(fname_tmp, "a") # encoding not specified, should use utf-8 38 | f.write( 39 | "\u0061\u0062\u0063\u0219\u021b\u005f\u1e69\u0073\u0323\u0307\u0073\u0307\u0323\u005f\u0431\u0434\u0436\u005f\u03b1\u03b2\u03ce\u005f\u0648\u062a\u005f\u05d0\u05e1\u05dc\u005f\u6c38\U0002a6a5\u9eb5\U00020000", 40 | ) 41 | f.close() 42 | passed = filecmp.cmp( 43 | os.path.join(dir, "assets/utf8-after-append.txt"), 44 | fname_tmp, 45 | shallow=False, 46 | ) 47 | assert passed is True 48 | os.close(ftmp) 49 | os.unlink(fname_tmp) 50 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | # spell-checker:ignore filterwarnings norecursedirs optionflags 2 | [pytest] 3 | # do not add options here as this will likely break either console runs or IDE 4 | # integration like vscode or pycharm 5 | addopts = 6 | # https://code.visualstudio.com/docs/python/testing 7 | # coverage is re-enabled in `tox.ini`. That approach is safer than 8 | # `--no-cov` which prevents activation from tox.ini and which also fails 9 | # when plugin is effectively missing. 10 | -p no:pytest_cov 11 | 12 | doctest_optionflags = ALLOW_UNICODE ELLIPSIS 13 | filterwarnings = 14 | default 15 | ignore:.*mode is deprecated:Warning 16 | ignore:unclosed file.*:Warning 17 | ignore:can't resolve package from.*:Warning 18 | 19 | junit_duration_report = call 20 | # Our github annotation parser from .github/workflows/tox.yml requires xunit1 format. Ref: 21 | # https://github.com/shyim/junit-report-annotations-action/issues/3#issuecomment-663241378 22 | junit_family = xunit1 23 | junit_suite_name = ansible_lint_test_suite 24 | minversion = 4.6.6 25 | norecursedirs = 26 | build 27 | dist 28 | docs 29 | .cache 30 | .eggs 31 | .git 32 | .github 33 | .tox 34 | *.egg 35 | python_files = 36 | test_*.py 37 | # Ref: https://docs.pytest.org/en/latest/reference.html#confval-python_files 38 | # Needed to discover legacy nose test modules: 39 | Test*.py 40 | # Needed to discover embedded Rule tests 41 | # Using --pyargs instead of testpath as we embed some tests 42 | # See: https://github.com/pytest-dev/pytest/issues/6451#issuecomment-687043537 43 | # testpaths = 44 | xfail_strict = true 45 | -------------------------------------------------------------------------------- /src/tendo/tests/test_singleton.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from multiprocessing import Process 4 | 5 | from tendo.singleton import SingleInstance, SingleInstanceException 6 | 7 | logger = logging.getLogger("tendo.singleton.test") 8 | logger.addHandler(logging.StreamHandler()) 9 | logger.setLevel(logging.DEBUG) 10 | 11 | 12 | def f(name): 13 | tmp = logger.level 14 | logger.setLevel(logging.CRITICAL) # we do not want to see the warning 15 | try: 16 | with SingleInstance(flavor_id=name): 17 | pass 18 | except SingleInstanceException: 19 | sys.exit(-1) 20 | logger.setLevel(tmp) 21 | 22 | 23 | def test_singleton_from_script(): 24 | with SingleInstance(flavor_id="test-1"): 25 | pass 26 | # now the lock should be removed 27 | assert True 28 | 29 | 30 | def test_singleton_from_process(): 31 | p = Process(target=f, args=("test-2",)) 32 | p.start() 33 | p.join() 34 | # the called function should succeed 35 | assert p.exitcode == 0, "%s != 0" % p.exitcode 36 | 37 | 38 | def test_multiple_singletons_from_process(): 39 | with SingleInstance(flavor_id="test-3"): 40 | p = Process(target=f, args=("test-3",)) 41 | p.start() 42 | p.join() 43 | # the called function should fail because we already have another 44 | # instance running 45 | assert p.exitcode != 0, "%s != 0 (2nd execution)" % p.exitcode 46 | # note, we return -1 but this translates to 255 meanwhile we'll 47 | # consider that anything different from 0 is good 48 | p = Process(target=f, args=("test-3",)) 49 | p.start() 50 | p.join() 51 | # the called function should fail because we already have another 52 | # instance running 53 | assert p.exitcode != 0, "%s != 0 (3rd execution)" % p.exitcode 54 | 55 | 56 | def test_singleton_lock_file(): 57 | lockfile = "/tmp/foo.lock" 58 | with SingleInstance(lockfile=lockfile) as me: 59 | print(me) 60 | assert me.lockfile == lockfile 61 | -------------------------------------------------------------------------------- /src/tendo/execfile2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import shlex 3 | import sys 4 | 5 | if sys.hexversion > 0x03000000: 6 | 7 | def execfile(file, globals=globals(), locals=locals()): 8 | fh = open(file) 9 | if not fh: 10 | raise Exception("Unable to open %s." % file) 11 | exec(fh.read() + "\n", globals, locals) 12 | 13 | 14 | def execfile2(filename, _globals=dict(), _locals=dict(), cmd=None, quiet=False): 15 | """Execute a Python script using :py:func:`execfile`. 16 | 17 | In addition to Python :py:func:`execfile` this method can temporary change the argv params. 18 | 19 | This enables you to call an external python script that requires 20 | command line arguments without leaving current python interpretor. 21 | 22 | `cmd` can be a string with command line arguments or a list or arguments 23 | 24 | The return value is a numeric exit code similar to the one used for command line tools: 25 | 26 | - 0 - if succesfull; this applies if script receives SystemExit with error code 0 27 | - 1 - if SystemExit does not contain an error code or if other Exception is received. 28 | - x - the SystemExit error code (if present) 29 | """ 30 | _globals["__name__"] = "__main__" 31 | saved_argv = sys.argv # we save sys.argv 32 | if cmd: 33 | sys.argv = list([filename]) 34 | if isinstance(cmd, list): 35 | sys.argv.append(cmd) 36 | else: 37 | sys.argv.extend(shlex.split(cmd)) 38 | exit_code = 0 39 | try: 40 | exec(compile(open(filename).read(), filename, "exec"), _globals, _locals) 41 | 42 | except SystemExit: 43 | type, e, tb = sys.exc_info() 44 | if isinstance(e.code, int): 45 | exit_code = e.code # this could be 0 if you do sys.exit(0) 46 | else: 47 | exit_code = 1 48 | except Exception: 49 | if not quiet: 50 | import traceback 51 | 52 | traceback.print_exc(file=sys.stderr) 53 | exit_code = 1 54 | finally: 55 | if cmd: 56 | sys.argv = saved_argv # we restore sys.argv 57 | return exit_code 58 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 2 | -------------------------------------------- 3 | 4 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 5 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 6 | otherwise using this software ("Python") in source or binary form and 7 | its associated documentation. 8 | 9 | 2. Subject to the terms and conditions of this License Agreement, PSF 10 | hereby grants Licensee a nonexclusive, royalty-free, world-wide 11 | license to reproduce, analyze, test, perform and/or display publicly, 12 | prepare derivative works, distribute, and otherwise use Python 13 | alone or in any derivative version, provided, however, that PSF's 14 | License Agreement and PSF's notice of copyright, i.e., "Copyright (c) 15 | 2001, 2002, 2003, 2004 Python Software Foundation; All Rights Reserved" 16 | are retained in Python alone or in any derivative version prepared 17 | by Licensee. 18 | 19 | 3. In the event Licensee prepares a derivative work that is based on 20 | or incorporates Python or any part thereof, and wants to make 21 | the derivative work available to others as provided herein, then 22 | Licensee hereby agrees to include in any such work a brief summary of 23 | the changes made to Python. 24 | 25 | 4. PSF is making Python available to Licensee on an "AS IS" 26 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 27 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 28 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 29 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 30 | INFRINGE ANY THIRD PARTY RIGHTS. 31 | 32 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 33 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 34 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 35 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 36 | 37 | 6. This License Agreement will automatically terminate upon a material 38 | breach of its terms and conditions. 39 | 40 | 7. Nothing in this License Agreement shall be deemed to create any 41 | relationship of agency, partnership, or joint venture between PSF and 42 | Licensee. This License Agreement does not grant permission to use PSF 43 | trademarks or trade name in a trademark sense to endorse or promote 44 | products or services of Licensee, or any third party. 45 | 46 | 8. By copying, installing or otherwise using Python, Licensee 47 | agrees to be bound by the terms and conditions of this License 48 | Agreement. 49 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | site_name: Tendo Python Library 3 | site_url: https://github.com/pycontribs/tendo 4 | repo_url: https://github.com/pycontribs/tendo 5 | edit_uri: blob/main/docs/ 6 | copyright: Copyright © 2017-2024 Sorin Sbarnea 7 | docs_dir: docs 8 | strict: true 9 | watch: 10 | - mkdocs.yml 11 | - src 12 | - docs 13 | 14 | theme: 15 | name: ansible 16 | features: 17 | - content.code.copy 18 | - content.action.edit 19 | - navigation.expand 20 | - navigation.sections 21 | - navigation.instant 22 | - navigation.indexes 23 | - navigation.tracking 24 | - toc.integrate 25 | extra: 26 | social: 27 | - icon: fontawesome/brands/github-alt 28 | link: https://github.com/pycontribs/tendo 29 | name: GitHub 30 | nav: 31 | - tendo: index.md 32 | plugins: 33 | - autorefs 34 | - search 35 | - material/social 36 | - material/tags 37 | - mkdocstrings: 38 | handlers: 39 | python: 40 | paths: [src] 41 | options: 42 | # heading_level: 2 43 | docstring_style: sphinx 44 | docstring_options: 45 | ignore_init_summary: yes 46 | 47 | show_submodules: no 48 | docstring_section_style: list 49 | members_order: alphabetical 50 | show_category_heading: no 51 | # cannot merge init into class due to parse error... 52 | # merge_init_into_class: yes 53 | # separate_signature: yes 54 | show_root_heading: yes 55 | show_signature_annotations: yes 56 | separate_signature: yes 57 | # show_bases: false 58 | inventories: 59 | - url: https://docs.ansible.com/ansible/latest/objects.inv 60 | domains: [py, std] 61 | # options: 62 | # show_root_heading: true 63 | # docstring_style: sphinx 64 | 65 | markdown_extensions: 66 | - markdown_include.include: 67 | base_path: docs 68 | - admonition 69 | - def_list 70 | - footnotes 71 | - pymdownx.highlight: 72 | anchor_linenums: true 73 | - pymdownx.inlinehilite 74 | - pymdownx.superfences 75 | - pymdownx.magiclink: 76 | repo_url_shortener: true 77 | repo_url_shorthand: true 78 | social_url_shorthand: true 79 | social_url_shortener: true 80 | user: facelessuser 81 | repo: pymdown-extensions 82 | normalize_issue_symbols: true 83 | - pymdownx.tabbed: 84 | alternate_style: true 85 | - toc: 86 | toc_depth: 2 87 | permalink: true 88 | - pymdownx.superfences: 89 | custom_fences: 90 | - name: mermaid 91 | class: mermaid 92 | format: !!python/name:pymdownx.superfences.fence_code_format 93 | -------------------------------------------------------------------------------- /src/tendo/singleton.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import fcntl 4 | import logging 5 | import os 6 | import sys 7 | import tempfile 8 | 9 | 10 | class SingleInstanceException(BaseException): 11 | pass 12 | 13 | 14 | class SingleInstance: 15 | """Class that can be instantiated only once per machine. 16 | 17 | If you want to prevent your script from running in parallel just instantiate SingleInstance() class. If is there another instance already running it will throw a `SingleInstanceException`. 18 | 19 | >>> import tendo 20 | ... me = SingleInstance() 21 | 22 | This option is very useful if you have scripts executed by crontab at small amounts of time. 23 | 24 | Remember that this works by creating a lock file with a filename based on the full path to the script file. 25 | 26 | Providing a flavor_id will augment the filename with the provided flavor_id, allowing you to create multiple singleton instances from the same file. This is particularly useful if you want specific functions to have their own singleton instances. 27 | """ 28 | 29 | def __init__(self, flavor_id="", lockfile=""): 30 | self.initialized = False 31 | if lockfile: 32 | self.lockfile = lockfile 33 | else: 34 | basename = ( 35 | os.path.splitext(os.path.abspath(sys.argv[0]))[0] 36 | .replace("/", "-") 37 | .replace(":", "") 38 | .replace("\\", "-") 39 | + "-%s" % flavor_id 40 | + ".lock" 41 | ) 42 | self.lockfile = os.path.normpath(tempfile.gettempdir() + "/" + basename) 43 | 44 | logger.debug(f"SingleInstance lockfile: {self.lockfile}") 45 | 46 | def __enter__(self): 47 | self.fp = open(self.lockfile, "w") 48 | self.fp.flush() 49 | try: 50 | fcntl.lockf(self.fp, fcntl.LOCK_EX | fcntl.LOCK_NB) 51 | except OSError: 52 | logger.warning("Another instance is already running, quitting.") 53 | raise SingleInstanceException 54 | self.initialized = True 55 | return self 56 | 57 | def __exit__(self, exc_type, exc_value, exc_tb): 58 | if not self.initialized: 59 | return 60 | if exc_value is not None: 61 | logger.warning("Error: %s" % exc_value, exc_info=True) 62 | try: 63 | fcntl.lockf(self.fp, fcntl.LOCK_UN) 64 | # os.close(self.fp) 65 | if os.path.isfile(self.lockfile): 66 | os.unlink(self.lockfile) 67 | except Exception as e: 68 | if logger: 69 | logger.warning(e) 70 | else: 71 | print(f"Unloggable error: {e}") 72 | if exc_value is not None: 73 | raise e from exc_value 74 | sys.exit(-1) 75 | 76 | 77 | logger = logging.getLogger("tendo.singleton") 78 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ci: 3 | # format compatible with commitlint 4 | autoupdate_commit_msg: "chore: pre-commit autoupdate" 5 | autoupdate_schedule: monthly 6 | autofix_commit_msg: "chore: auto fixes from pre-commit.com hooks" 7 | skip: 8 | # https://github.com/pre-commit-ci/issues/issues/55 9 | - pip-compile 10 | submodules: true 11 | repos: 12 | - repo: meta 13 | hooks: 14 | - id: check-useless-excludes 15 | - repo: https://github.com/pappasam/toml-sort 16 | rev: v0.24.2 17 | hooks: 18 | - id: toml-sort-fix 19 | 20 | - repo: https://github.com/tox-dev/tox-ini-fmt 21 | rev: 1.5.0 22 | hooks: 23 | - id: tox-ini-fmt 24 | 25 | - repo: https://github.com/astral-sh/ruff-pre-commit 26 | rev: "v0.11.7" 27 | hooks: 28 | - id: ruff 29 | entry: sh -c 'ruff check --fix --force-exclude && ruff format --force-exclude' 30 | types_or: [python, pyi] 31 | - repo: https://github.com/asottile/pyupgrade 32 | # keep it after flake8 33 | rev: v3.19.1 34 | hooks: 35 | - id: pyupgrade 36 | args: ["--py310-plus"] 37 | - repo: https://github.com/pre-commit/pre-commit-hooks 38 | rev: v5.0.0 39 | hooks: 40 | - id: trailing-whitespace 41 | exclude: ^src/tendo/tests/.*\.txt$ 42 | - id: end-of-file-fixer 43 | exclude: ^src/tendo/tests/.*\.txt$ 44 | - id: mixed-line-ending 45 | exclude: ^src/tendo/tests/.*\.txt$ 46 | - id: check-byte-order-marker 47 | exclude: ^src/tendo/tests/.*\.txt$ 48 | - id: check-executables-have-shebangs 49 | - id: check-merge-conflict 50 | - id: check-symlinks 51 | - id: check-vcs-permalinks 52 | - id: debug-statements 53 | - id: requirements-txt-fixer 54 | - id: check-yaml 55 | files: .*\.(yaml|yml)$ 56 | exclude: > 57 | (?x)^( 58 | mkdocs.yml 59 | )$ 60 | - repo: https://github.com/adrienverge/yamllint.git 61 | rev: v1.37.0 62 | hooks: 63 | - id: yamllint 64 | files: \.(yaml|yml)$ 65 | 66 | - # keep at bottom as these are slower 67 | repo: local 68 | hooks: 69 | - id: deps 70 | name: Upgrade constraints files and requirements 71 | files: ^(pyproject\.toml|.config/.*)$ 72 | always_run: true 73 | language: python 74 | entry: python3 -m uv pip compile -q --all-extras --python-version=3.10 --output-file=.config/constraints.txt pyproject.toml --upgrade 75 | pass_filenames: false 76 | stages: 77 | - manual 78 | additional_dependencies: 79 | - uv>=0.6.6 80 | - id: pip-compile 81 | name: Check constraints files and requirements 82 | files: ^(pyproject\.toml|\.config/.*)$ 83 | language: python 84 | entry: uv pip compile -q --all-extras --python-version=3.10 --output-file=.config/constraints.txt pyproject.toml 85 | pass_filenames: false 86 | additional_dependencies: 87 | - uv>=0.6.6 88 | -------------------------------------------------------------------------------- /src/tendo/unicode.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import codecs 3 | import logging 4 | import sys 5 | 6 | """ 7 | Author: Sorin Sbarnea 8 | 9 | This file does add some additional Unicode support to Python, like: 10 | * auto-detect BOM on text-file open: open(file, "r") and open(file, "rU") 11 | 12 | """ 13 | # we save the file function handler because we want to override it 14 | open_old = open 15 | _logger = logging.getLogger() 16 | 17 | 18 | def b(s): 19 | return s.encode("latin-1") 20 | 21 | 22 | def open(filename, mode="r", bufsize=-1, fallback_encoding="utf_8"): 23 | """This replaces Python original function with an improved version that is Unicode aware. 24 | 25 | The new `open()` does change behaviour only for text files, not binary. 26 | 27 | * mode is by default 'r' if not specified and text mode 28 | * negative bufsize makes it use the default system one (same as not specified) 29 | 30 | >>> import tendo.unicode 31 | ... f = open("file-with-unicode-content.txt") 32 | ... content = f.read() # Unicode content of the file, without BOM 33 | 34 | Shortly by importing unicode, you will repair code that previously was broken when the input files were Unicode. 35 | 36 | This will not change the behavior of code that reads the files as binary, it has an effect on text file operations. 37 | 38 | Files with BOM will be read properly as Unicode and the BOM will not be part of the text. 39 | 40 | If you do not specify the fallback_encoding, files without BOM will be read as `UTF-8` instead of `ascii`. 41 | """ 42 | # Do not assign None to bufsize or mode because calling original open will 43 | # fail 44 | 45 | # we read the first 4 bytes just to be sure we use the right encoding 46 | # we are interested of detecting the mode only for read text 47 | if "r" in mode or "a" in mode: 48 | try: 49 | f = open_old(filename, "rb") 50 | aBuf = bytes(f.read(4)) 51 | f.close() 52 | except Exception: 53 | aBuf = b("") 54 | if bytes(aBuf[:3]) == b("\xef\xbb\xbf"): 55 | f = codecs.open(filename, mode, "utf_8") 56 | f.seek(3, 0) 57 | f.BOM = codecs.BOM_UTF8 58 | elif bytes(aBuf[:2]) == b("\xff\xfe"): 59 | f = codecs.open(filename, mode, "utf_16_le") 60 | f.seek(2, 0) 61 | f.BOM = codecs.BOM_UTF16_LE 62 | elif bytes(aBuf[:2]) == b("\xfe\xff"): 63 | f = codecs.open(filename, mode, "utf_16_be") 64 | f.seek(2, 0) 65 | f.BOM = codecs.BOM_UTF16_BE 66 | elif bytes(aBuf[:4]) == b("\xff\xfe\x00\x00"): 67 | f = codecs.open(filename, mode, "utf_32_le") 68 | f.seek(4, 0) 69 | f.BOM = codecs.BOM_UTF32_LE 70 | elif bytes(aBuf[:4]) == b("\x00\x00\xfe\xff"): 71 | f = codecs.open(filename, mode, "utf_32_be") 72 | f.seek(4, 0) 73 | f.BOM = codecs.BOM_UTF32_BE 74 | else: # we assume that if there is no BOM, the encoding is UTF-8 75 | f = codecs.open(filename, mode, fallback_encoding) 76 | f.seek(0) 77 | f.BOM = None 78 | return f 79 | import traceback 80 | 81 | _logger.warning( 82 | "Calling unicode.open({},{},{}) that may be wrong.".format( 83 | filename, mode, bufsize 84 | ), 85 | ) 86 | traceback.print_exc(file=sys.stderr) 87 | 88 | return open_old(filename, mode, bufsize) 89 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | setuptools>=65.3 4 | tox>=4.24.2 5 | tox-extra>=2.1 6 | tox-uv>=1.25 7 | env_list = 8 | py 9 | devel 10 | lint 11 | pkg 12 | docs 13 | skip_missing_interpreters = true 14 | 15 | [testenv] 16 | description = 17 | Run the tests 18 | devel: and ansible devel branch 19 | pre: and enable --pre when installing dependencies, testing prereleases 20 | package = editable 21 | extras = 22 | test 23 | pass_env = 24 | CI 25 | CURL_CA_BUNDLE 26 | FORCE_COLOR 27 | HOME 28 | LANG 29 | LC_* 30 | NO_COLOR 31 | PYTEST_* 32 | PYTEST_REQPASS 33 | PYTHON* 34 | PYTHONBREAKPOINT 35 | PYTHONIOENCODING 36 | PYTHONPYCACHEPREFIX 37 | PY_COLORS 38 | REQUESTS_CA_BUNDLE 39 | RTD_TOKEN 40 | SETUPTOOLS_SCM_DEBUG 41 | SSH_AUTH_SOCK 42 | SSL_CERT_FILE 43 | UV_* 44 | set_env = 45 | COVERAGE_FILE = {env:COVERAGE_FILE:{env_dir}/.coverage.{env_name}} 46 | COVERAGE_PROCESS_START = {tox_root}/pyproject.toml 47 | FORCE_COLOR = 1 48 | PIP_CONSTRAINT = {tox_root}/.config/constraints.txt 49 | PIP_DISABLE_PIP_VERSION_CHECK = 1 50 | PRE_COMMIT_COLOR = always 51 | UV_CONSTRAINT = {tox_root}/.config/constraints.txt 52 | deps, devel, lint, pkg, pre: PIP_CONSTRAINT = /dev/null 53 | deps, devel, lint, pkg, pre: UV_CONSTRAINT = /dev/null 54 | lower: PIP_CONSTRAINT = {tox_root}/.github/lower-constraints.txt 55 | lower: UV_CONSTRAINT = {tox_root}/.github/lower-constraints.txt 56 | pre: PIP_PRE = 1 57 | commands_pre = 58 | {envpython} -m pip check 59 | sh -c "rm -f {envdir}/.coverage.* 2>/dev/null || true" 60 | commands = 61 | coverage run -m pytest {posargs:} 62 | {py,py39,py310,py311,py312,py313}: sh -c "coverage combine -q --data-file={envdir}/.coverage {envdir}/.coverage.* && coverage xml --data-file={envdir}/.coverage -o {envdir}/coverage.xml --ignore-errors --fail-under=0 && COVERAGE_FILE={envdir}/.coverage coverage lcov --fail-under=0 --ignore-errors -q && COVERAGE_FILE={envdir}/.coverage coverage report --fail-under=0 --ignore-errors" 63 | allowlist_externals = 64 | sh 65 | editable = true 66 | 67 | [testenv:lint] 68 | description = Run all linters 69 | skip_install = true 70 | deps = 71 | pre-commit>=4.1 72 | pre-commit-uv>=4.1.4 73 | pytest>=7.2.2 # to updated schemas 74 | setuptools>=51.1.1 75 | pass_env = 76 | {[testenv]pass_env} 77 | PRE_COMMIT_HOME 78 | commands_pre = 79 | commands = 80 | {env_python} -m pre_commit run --all-files --show-diff-on-failure {posargs:} 81 | 82 | [testenv:pkg] 83 | description = 84 | Build package, verify metadata, install package and assert behavior when ansible is missing. 85 | skip_install = true 86 | deps = 87 | build>=0.9 88 | pip 89 | pipx 90 | twine>=4.0.1 91 | commands_pre = 92 | commands = 93 | {env_python} -c 'import os.path, shutil, sys; \ 94 | dist_dir = os.path.join("{tox_root}", "dist"); \ 95 | os.path.isdir(dist_dir) or sys.exit(0); \ 96 | print("Removing \{!s\} contents...".format(dist_dir), file=sys.stderr); \ 97 | shutil.rmtree(dist_dir)' 98 | {env_python} -m build --outdir {tox_root}/dist/ {tox_root} 99 | python3 -m twine check --strict {tox_root}/dist/* 100 | sh -c "python3 -m pip install {tox_root}/dist/*.whl" 101 | 102 | [testenv:docs] 103 | description = Build docs 104 | package = editable 105 | skip_install = false 106 | extras = 107 | docs 108 | set_env = 109 | DYLD_FALLBACK_LIBRARY_PATH = /opt/homebrew/lib:{env:LD_LIBRARY_PATH} 110 | NO_COLOR = 1 111 | TERM = dump 112 | commands_pre = 113 | commands = 114 | mkdocs {posargs:build --strict --site-dir=_readthedocs/html/} 115 | 116 | [testenv:deps] 117 | description = Bump all test dependencies 118 | skip_install = true 119 | deps = 120 | {[testenv:lint]deps} 121 | commands_pre = 122 | commands = 123 | pre-commit run --all-files --show-diff-on-failure --hook-stage manual deps 124 | pre-commit autoupdate 125 | tox -e lint 126 | env_dir = {work_dir}/lint 127 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "setuptools.build_meta" 3 | requires = [ 4 | "setuptools >= 65.4.0", # required by pyproject+setuptools_scm integration 5 | "setuptools_scm[toml] >= 7.0.5" # required for "no-local-version" scheme 6 | ] 7 | 8 | [project] 9 | authors = [{"email" = "ssbarnea@redhat.com", "name" = "Sorin Sbarnea"}] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Environment :: Console", 13 | "Intended Audience :: Developers", 14 | "Intended Audience :: Information Technology", 15 | "Intended Audience :: System Administrators", 16 | "Operating System :: MacOS", 17 | "Operating System :: POSIX :: Linux", 18 | "Operating System :: POSIX", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python", 25 | "Topic :: Internet :: WWW/HTTP", 26 | "Topic :: Software Development :: Bug Tracking", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | "Topic :: Software Development :: Quality Assurance", 29 | "Topic :: Software Development :: Testing", 30 | "Topic :: System :: Systems Administration", 31 | "Topic :: Utilities" 32 | ] 33 | description = "A Python library that extends some core functionality" 34 | dynamic = ["version", "dependencies", "optional-dependencies"] 35 | keywords = [ 36 | "tendo", 37 | "tee", 38 | "unicode", 39 | "singleton" 40 | ] 41 | license = "MIT" 42 | maintainers = [{"email" = "ssbarnea@redhat.com", "name" = "Sorin Sbarnea"}] 43 | name = "tendo" 44 | readme = "README.md" 45 | # https://peps.python.org/pep-0621/#readme 46 | requires-python = ">=3.10" 47 | 48 | [project.urls] 49 | changelog = "https://github.com/pycontribs/tendo/releases" 50 | documentation = "https://tendo.readthedocs.io" 51 | homepage = "https://github.com/pycontribs/tendo" 52 | repository = "https://github.com/pycontribs/tendo" 53 | 54 | [tool.coverage.report] 55 | exclude_also = ["pragma: no cover", "if TYPE_CHECKING:"] 56 | fail_under = 100 57 | show_missing = true 58 | skip_covered = true 59 | skip_empty = true 60 | 61 | [tool.coverage.run] 62 | # Do not use branch until bug is fixes: 63 | # https://github.com/nedbat/coveragepy/issues/605 64 | branch = false 65 | concurrency = ["multiprocessing", "thread"] 66 | parallel = true 67 | source = ["src"] 68 | 69 | [tool.isort] 70 | add_imports = "from __future__ import annotations" 71 | profile = "black" 72 | 73 | [tool.ruff] 74 | target-version = "py310" 75 | 76 | [tool.ruff.lint] 77 | ignore = [ 78 | # Disabled on purpose: 79 | "ANN101", # Missing type annotation for `self` in method 80 | "D203", # incompatible with D211 81 | "D211", 82 | "D213", # incompatible with D212 83 | "E501", # we use black 84 | "RET504", # Unnecessary variable assignment before `return` statement 85 | "COM812", # conflicts with ISC001 on format 86 | "ISC001", # conflicts with COM812 on format 87 | # Temporary disabled during adoption: 88 | "A001", 89 | "A002", 90 | "ANN", 91 | "ARG", 92 | "B", 93 | "BLE001", 94 | "C", 95 | "C901", 96 | "D", 97 | "EM101", 98 | "ERA", 99 | "EXE", 100 | "FBT", 101 | "FURB", 102 | "G", 103 | "N", 104 | "PGH", 105 | "PLR", 106 | "PLW", 107 | "PT", 108 | "PTH", 109 | "RUF012", 110 | "S", 111 | "S101", 112 | "SIM", 113 | "SIM115", 114 | "SLF", 115 | "T", 116 | "TRY", 117 | "UP", 118 | "UP031" 119 | ] 120 | select = ["ALL"] 121 | 122 | [tool.ruff.lint.isort] 123 | known-first-party = ["src"] 124 | 125 | [tool.ruff.lint.pydocstyle] 126 | convention = "google" 127 | 128 | [tool.setuptools.dynamic] 129 | dependencies = {file = [".config/requirements.in"]} 130 | optional-dependencies.docs = {file = [".config/requirements-docs.in"]} 131 | optional-dependencies.test = {file = [".config/requirements-test.in"]} 132 | 133 | [tool.setuptools_scm] 134 | # To prevent accidental pick of mobile version tags such 'v6' 135 | git_describe_command = [ 136 | "git", 137 | "describe", 138 | "--dirty", 139 | "--long", 140 | "--tags", 141 | "--match", 142 | "v*.*" 143 | ] 144 | local_scheme = "no-local-version" 145 | tag_regex = "^(?Pv)?(?P\\d+[^\\+]*)(?P.*)?$" 146 | write_to = "src/tendo/_version.py" 147 | 148 | [tool.tomlsort] 149 | in_place = true 150 | sort_inline_tables = true 151 | sort_table_keys = true 152 | 153 | [tool.uv.pip] 154 | annotation-style = "line" 155 | custom-compile-command = "tox run -e deps" 156 | -------------------------------------------------------------------------------- /.config/constraints.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # tox run -e deps 3 | attrs==25.3.0 # via pytest 4 | babel==2.17.0 # via mkdocs-material 5 | backrefs==5.8 # via mkdocs-material 6 | beautifulsoup4==4.13.4 # via linkchecker, mkdocs-htmlproofer-plugin 7 | cairocffi==1.7.1 # via cairosvg 8 | cairosvg==2.7.1 # via mkdocs-ansible 9 | certifi==2025.4.26 # via requests 10 | cffi==1.17.1 # via cairocffi 11 | cfgv==3.4.0 # via pre-commit 12 | charset-normalizer==3.4.1 # via requests 13 | click==8.1.8 # via mkdocs 14 | colorama==0.4.6 # via griffe, mkdocs-material 15 | coverage==6.5.0 # via coveralls, pytest-cov, tendo (pyproject.toml) 16 | coveralls==3.3.1 # via tendo (pyproject.toml) 17 | csscompressor==0.9.5 # via mkdocs-minify-plugin 18 | cssselect2==0.8.0 # via cairosvg 19 | defusedxml==0.7.1 # via cairosvg 20 | distlib==0.3.9 # via virtualenv 21 | dnspython==2.7.0 # via linkchecker 22 | docopt==0.6.2 # via coveralls 23 | execnet==2.1.1 # via pytest-cache, pytest-xdist 24 | filelock==3.18.0 # via virtualenv 25 | ghp-import==2.1.0 # via mkdocs 26 | griffe==1.7.3 # via mkdocstrings-python 27 | hjson==3.1.0 # via mkdocs-macros-plugin, super-collections 28 | htmlmin2==0.1.13 # via mkdocs-minify-plugin 29 | identify==2.6.10 # via pre-commit 30 | idna==3.10 # via requests 31 | iniconfig==2.1.0 # via pytest 32 | jinja2==3.1.6 # via mkdocs, mkdocs-macros-plugin, mkdocs-material, mkdocstrings 33 | jsmin==3.0.1 # via mkdocs-minify-plugin 34 | linkchecker==10.5.0 # via mkdocs-ansible 35 | markdown==3.8 # via markdown-include, mkdocs, mkdocs-autorefs, mkdocs-htmlproofer-plugin, mkdocs-material, mkdocstrings, pymdown-extensions 36 | markdown-exec==1.10.3 # via mkdocs-ansible 37 | markdown-include==0.8.1 # via mkdocs-ansible 38 | markupsafe==3.0.2 # via jinja2, mkdocs, mkdocs-autorefs, mkdocstrings 39 | mergedeep==1.3.4 # via mkdocs, mkdocs-get-deps 40 | mkdocs==1.6.1 # via mkdocs-ansible, mkdocs-autorefs, mkdocs-gen-files, mkdocs-htmlproofer-plugin, mkdocs-macros-plugin, mkdocs-material, mkdocs-minify-plugin, mkdocs-monorepo-plugin, mkdocstrings 41 | mkdocs-ansible==25.2.0 # via tendo (pyproject.toml) 42 | mkdocs-autorefs==1.4.1 # via mkdocstrings, mkdocstrings-python 43 | mkdocs-gen-files==0.5.0 # via mkdocs-ansible 44 | mkdocs-get-deps==0.2.0 # via mkdocs 45 | mkdocs-htmlproofer-plugin==1.3.0 # via mkdocs-ansible 46 | mkdocs-macros-plugin==1.3.7 # via mkdocs-ansible 47 | mkdocs-material==9.6.12 # via mkdocs-ansible 48 | mkdocs-material-extensions==1.3.1 # via mkdocs-ansible, mkdocs-material 49 | mkdocs-minify-plugin==0.8.0 # via mkdocs-ansible 50 | mkdocs-monorepo-plugin==1.1.0 # via mkdocs-ansible 51 | mkdocstrings==0.29.1 # via mkdocs-ansible, mkdocstrings-python 52 | mkdocstrings-python==1.16.10 # via mkdocs-ansible 53 | nodeenv==1.9.1 # via pre-commit 54 | packaging==25.0 # via mkdocs, mkdocs-macros-plugin, pytest 55 | paginate==0.5.7 # via mkdocs-material 56 | pathspec==0.12.1 # via mkdocs, mkdocs-macros-plugin 57 | pillow==11.2.1 # via cairosvg, mkdocs-ansible 58 | pip==25.1 # via tendo (pyproject.toml) 59 | platformdirs==4.3.7 # via mkdocs-get-deps, virtualenv 60 | pluggy==1.5.0 # via pytest 61 | pre-commit==4.2.0 # via tendo (pyproject.toml) 62 | py==1.11.0 # via pytest, pytest-forked 63 | pycparser==2.22 # via cffi 64 | pygments==2.19.1 # via mkdocs-material 65 | pymdown-extensions==10.15 # via markdown-exec, mkdocs-ansible, mkdocs-material, mkdocstrings 66 | pytest==7.1.3 # via pytest-cache, pytest-cov, pytest-forked, pytest-html, pytest-instafail, pytest-metadata, pytest-xdist, tendo (pyproject.toml) 67 | pytest-cache==1.0 # via tendo (pyproject.toml) 68 | pytest-cov==3.0.0 # via tendo (pyproject.toml) 69 | pytest-forked==1.6.0 # via pytest-xdist 70 | pytest-html==3.1.1 # via tendo (pyproject.toml) 71 | pytest-instafail==0.4.2 # via tendo (pyproject.toml) 72 | pytest-metadata==3.1.1 # via pytest-html 73 | pytest-xdist==2.5.0 # via tendo (pyproject.toml) 74 | python-dateutil==2.9.0.post0 # via ghp-import, mkdocs-macros-plugin 75 | python-slugify==8.0.4 # via mkdocs-monorepo-plugin 76 | pyyaml==6.0.2 # via mkdocs, mkdocs-get-deps, mkdocs-macros-plugin, pre-commit, pymdown-extensions, pyyaml-env-tag 77 | pyyaml-env-tag==0.1 # via mkdocs 78 | requests==2.32.3 # via coveralls, linkchecker, mkdocs-htmlproofer-plugin, mkdocs-material 79 | six==1.17.0 # via python-dateutil 80 | soupsieve==2.7 # via beautifulsoup4 81 | super-collections==0.5.3 # via mkdocs-macros-plugin 82 | termcolor==3.0.1 # via mkdocs-macros-plugin 83 | text-unidecode==1.3 # via python-slugify 84 | tinycss2==1.4.0 # via cairosvg, cssselect2 85 | tomli==2.2.1 # via coverage, pytest 86 | typing-extensions==4.13.2 # via beautifulsoup4, mkdocstrings-python 87 | urllib3==2.4.0 # via requests 88 | virtualenv==20.30.0 # via pre-commit 89 | watchdog==6.0.0 # via mkdocs 90 | webencodings==0.5.1 # via cssselect2, tinycss2 91 | wheel==0.37.1 # via tendo (pyproject.toml) 92 | -------------------------------------------------------------------------------- /src/tendo/tee.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import codecs 3 | import logging 4 | import os 5 | import subprocess 6 | import sys 7 | import time 8 | import types 9 | import unittest 10 | from shlex import quote 11 | 12 | global logger 13 | global stdout 14 | global stderr 15 | global timing 16 | global log_command 17 | 18 | logger = None 19 | stdout = False 20 | stderr = False 21 | # print execution time of each command in the log, just after the return code 22 | timing = True 23 | # outputs the command being executed to the log (before command output) 24 | log_command = True 25 | _sentinel = object() 26 | _logger = logging.getLogger() 27 | 28 | 29 | def quote_command(cmd): 30 | """This function does assure that the command line is entirely quoted. 31 | 32 | This is required in order to prevent getting "The input line is too long" error message. 33 | """ 34 | if '"' in cmd[1:-1]: 35 | cmd = '"' + cmd + '"' 36 | return cmd 37 | 38 | 39 | def system2( 40 | cmd, 41 | cwd=None, 42 | logger=_sentinel, 43 | stdout=_sentinel, 44 | log_command=_sentinel, 45 | timing=_sentinel, 46 | ): 47 | # def tee(cmd, cwd=None, logger=tee_logger, console=tee_console): 48 | """Works exactly like :func:`system` but it returns both the exit code and the output as a list of lines. 49 | 50 | This method returns a tuple: (return_code, output_lines_as_list). The return code of 0 means success. 51 | """ 52 | # if isinstance(cmd, collections.Iterable): # -- this line was replaced 53 | # because collections.Iterable seems to be missing on Debian Python 2.5.5 54 | # (but not on OS X 10.8 with Python 2.5.6) 55 | if hasattr(cmd, "__iter__"): 56 | cmd = " ".join(quote(s) for s in cmd) 57 | 58 | t = time.process_time() 59 | output = [] 60 | if log_command is _sentinel: 61 | log_command = globals().get("log_command") 62 | if timing is _sentinel: 63 | timing = globals().get("timing") 64 | 65 | # default to python native logger if logger parameter is not used 66 | if logger is _sentinel: 67 | logger = globals().get("logger") 68 | if stdout is _sentinel: 69 | stdout = globals().get("stdout") 70 | 71 | # logging.debug("logger=%s stdout=%s" % (logger, stdout)) 72 | 73 | f = sys.stdout 74 | if not f.encoding or f.encoding == "ascii": 75 | # `ascii` is not a valid encoding by our standards, it's better to output to UTF-8 because it can encoding any Unicode text 76 | encoding = "utf_8" 77 | else: 78 | encoding = f.encoding 79 | 80 | def filelogger(msg): 81 | try: 82 | # we'll use the same endline on all platforms, you like it or not 83 | msg += "\n" 84 | try: 85 | f.write(msg) 86 | except TypeError: 87 | f.write(msg.encode("utf-8")) 88 | except Exception: 89 | sys.exc_info()[1] 90 | import traceback 91 | 92 | print(f" ****** ERROR: Exception: {e}\nencoding = {encoding}") 93 | traceback.print_exc(file=sys.stderr) 94 | sys.exit(-1) 95 | 96 | def nop(msg): 97 | pass 98 | 99 | if not logger: 100 | mylogger = nop 101 | elif isinstance(logger, str): 102 | f = codecs.open(logger, "a+b", "utf_8") 103 | mylogger = filelogger 104 | elif isinstance( 105 | logger, 106 | (types.FunctionType, types.MethodType, types.BuiltinFunctionType), 107 | ): 108 | mylogger = logger 109 | else: 110 | method_write = getattr(logger, "write", None) 111 | # if we can call write() we'll aceppt it :D 112 | # this should work for filehandles 113 | if callable(method_write): 114 | f = logger 115 | mylogger = filelogger 116 | else: 117 | sys.exit("tee() does not support this type of logger=%s" % type(logger)) 118 | 119 | if cwd is not None and not os.path.isdir(cwd): 120 | os.makedirs(cwd) # this throws exception if fails 121 | 122 | cmd = quote_command(cmd) # to prevent _popen() bug 123 | p = subprocess.Popen( 124 | cmd, 125 | cwd=cwd, 126 | shell=True, 127 | stdout=subprocess.PIPE, 128 | stderr=subprocess.STDOUT, 129 | ) 130 | if log_command: 131 | mylogger("Running: %s" % cmd) 132 | while True: 133 | line = "" 134 | try: 135 | line = p.stdout.readline() 136 | line = line.decode(encoding) 137 | except Exception: 138 | e = sys.exc_info()[1] 139 | _logger.exception(e) 140 | _logger.exception( 141 | "The output of the command could not be decoded as %s\ncmd: %s\n line ignored: %s" 142 | % (encoding, cmd, repr(line)), 143 | ) 144 | 145 | output.append(line) 146 | if not line: 147 | break 148 | line = line.rstrip("\n\r") 149 | mylogger(line) # they are added by logging anyway 150 | if stdout: 151 | print(line) 152 | returncode = p.wait() 153 | if log_command: 154 | if timing: 155 | 156 | def secondsToStr(t): 157 | return time.strftime("%H:%M:%S", time.gmtime(t)) 158 | 159 | mylogger( 160 | "Returned: %d (execution time %s)\n" 161 | % (returncode, secondsToStr(time.process_time() - t)), 162 | ) 163 | else: 164 | mylogger("Returned: %d\n" % returncode) 165 | 166 | # running a tool that returns non-zero? this deserves a warning 167 | if returncode != 0: 168 | _logger.warning( 169 | "Returned: %d from: %s\nOutput %s", 170 | returncode, 171 | cmd, 172 | "\n".join(output), 173 | ) 174 | 175 | return returncode, output 176 | 177 | 178 | def system( 179 | cmd, 180 | cwd=None, 181 | logger=None, 182 | stdout=None, 183 | log_command=_sentinel, 184 | timing=_sentinel, 185 | ): 186 | """This works similar to :py:func:`os.system` but add some useful optional parameters. 187 | 188 | * ``cmd`` - command to be executed 189 | * ``cwd`` - optional working directory to be set before running cmd 190 | * ``logger`` - None, a filename, handle or a function like print or :py:meth:`logging.Logger.warning` 191 | 192 | Returns the exit code reported by the execution of the command, 0 means success. 193 | 194 | >>> import os, logging 195 | ... import tendo.tee 196 | ... tee.system("echo test", logger=logging.error) # output using python logging 197 | ... tee.system("echo test", logger="log.txt") # output to a file 198 | ... f = open("log.txt", "w") 199 | ... tee.system("echo test", logger=f) # output to a filehandle 200 | ... tee.system("echo test", logger=print) # use the print() function for output 201 | """ 202 | (returncode, output) = system2( 203 | cmd, 204 | cwd=cwd, 205 | logger=logger, 206 | stdout=stdout, 207 | log_command=log_command, 208 | timing=timing, 209 | ) 210 | return returncode 211 | 212 | 213 | class testTee(unittest.TestCase): 214 | def test_1(self): 215 | """No CMD os.system() 216 | 217 | 1 sort /? ok ok 218 | 2 "sort" /? ok ok 219 | 3 sort "/?" ok ok 220 | 4 "sort" "/?" ok [bad] 221 | 5 ""sort /?"" ok [bad] 222 | 6 "sort /?" [bad] ok 223 | 7 "sort "/?"" [bad] ok 224 | 8 ""sort" "/?"" [bad] ok 225 | """ 226 | quotes = { 227 | "dir >nul": "dir >nul", 228 | 'cd /D "C:\\Program Files\\"': '"cd /D "C:\\Program Files\\""', 229 | 'python -c "import os" dummy': '"python -c "import os" dummy"', 230 | "sort": "sort", 231 | } 232 | 233 | # we fake the os name because we want to run the test on any platform 234 | save = os.name 235 | os.name = "nt" 236 | 237 | for key, value in quotes.items(): 238 | resulted_value = quote_command(key) 239 | self.assertEqual( 240 | value, 241 | resulted_value, 242 | f"Returned <{resulted_value}>, expected <{value}>", 243 | ) 244 | # ret = os.system(resulted_value) 245 | # if not ret==0: 246 | # print("failed") 247 | os.name = save 248 | 249 | def test_2(self): 250 | self.assertEqual(system([sys.executable, "-V"]), 0) 251 | 252 | def test_3(self): 253 | self.assertEqual(system2([sys.executable, "-V"])[0], 0) 254 | 255 | def test_4(self): 256 | self.assertEqual(system([sys.executable, "-c", "print('c c')"]), 0) 257 | 258 | 259 | if __name__ == "__main__": 260 | # unittest.main() 261 | import pytest 262 | 263 | pytest.main([__file__]) 264 | 265 | # import pytest 266 | # pytest.main(['--pyargs', __name__]) 267 | """ 268 | import tempfile, os 269 | 270 | logging.basicConfig(level=logging.NOTSET, 271 | format='%(message)s') 272 | 273 | # default (stdout) 274 | print("#1") 275 | system("python --version") 276 | 277 | # function/method 278 | print("#2") 279 | system("python --version", logger=logging.error) 280 | 281 | # function (this is the same as default) 282 | print("#3") 283 | system("python --version", logger=print) 284 | 285 | # handler 286 | print("#4") 287 | f = tempfile.NamedTemporaryFile() 288 | system("python --version", logger=f) 289 | f.close() 290 | 291 | # test with string (filename) 292 | print("#5") 293 | (f, fname) = tempfile.mkstemp() 294 | system("python --version", logger=fname) 295 | os.close(f) 296 | os.unlink(fname) 297 | 298 | print("#6") 299 | stdout = False 300 | logger = None 301 | system("echo test") 302 | 303 | print("#7") 304 | stdout = True 305 | system("echo test2") 306 | 307 | """ 308 | --------------------------------------------------------------------------------