├── MANIFEST.in ├── .markdownlint.yml ├── docs ├── renameat2.rst ├── requirements.txt ├── index.md ├── README.md ├── Makefile ├── examples.md └── conf.py ├── requirements-dev.txt ├── .gitignore ├── setup.py ├── Makefile ├── .github ├── dependabot.yml └── workflows │ ├── bumpr.yml │ ├── test.yml │ ├── codeql-analysis.yml │ ├── lint.yml │ ├── suggestions.yml │ └── build_wheels.yml ├── .readthedocs.yaml ├── pyproject.toml ├── renameat2_build.py ├── .yamllint.yml ├── renameat2.c ├── LICENSE ├── setup.cfg ├── README.md ├── tests └── renameat2_test.py └── renameat2 └── __init__.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include renameat2_*.py 2 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | MD013: false 3 | -------------------------------------------------------------------------------- /docs/renameat2.rst: -------------------------------------------------------------------------------- 1 | API documentation 2 | ================= 3 | 4 | .. automodule:: renameat2 5 | :members: 6 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | cffi==1.15.1 2 | pytest==7.2.0 3 | setuptools==65.5.1 4 | setuptools-scm==7.1.0 5 | wheel==0.38.4 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .eggs 3 | .pytest_cache 4 | build 5 | dist 6 | docs/_build 7 | _renameat2.* 8 | *.egg-info 9 | *.o 10 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | importlib-metadata==6.0.0 2 | myst-parser==0.18.1 3 | readthedocs-sphinx-search==0.1.2 4 | Sphinx==5.3.0 5 | sphinx-rtd-theme==1.1.1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | cffi_modules=["renameat2_build.py:ffibuilder"], 5 | use_scm_version={"local_scheme": "no-local-version"}, 6 | ) 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | wheel: 2 | python3 setup.py bdist_wheel 3 | 4 | clean: 5 | rm -rf __pycache__ build dist docs/_build renameat2/_renameat2.* renameat2/__pycache__ renameat2/*.o 6 | # #git clean -fdx 7 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | ``` 3 | 4 | ## Documentation 5 | 6 | ```{eval-rst} 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | examples.md 11 | renameat2.rst 12 | 13 | * :ref:`genindex` 14 | * :ref:`modindex` 15 | * :ref:`search` 16 | ``` 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: ubuntu-20.04 8 | tools: 9 | python: "3" 10 | 11 | python: 12 | install: 13 | - requirements: requirements-dev.txt 14 | - method: setuptools 15 | path: . 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # python-renameat2 documentation 2 | 3 | If you are looking for the code that builds the documentation, you're in the right place. 4 | 5 | If you'd like to actually read the documentation, this directory will disappoint you. A readable version of the documentation is available at [ReadTheDocs](https://python-renameat2.readthedocs.io/en/latest/). 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools >= 40.6.0", 4 | "cffi", 5 | "wheel", 6 | "setuptools_scm[toml]>=3.4", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [tool.setuptools_scm] 11 | local_scheme = "no-local-version" 12 | 13 | [tool.isort] 14 | profile = "black" 15 | lines_between_types = 1 16 | 17 | [tool.cibuildwheel] 18 | build = "cp37-* cp38-* cp39-* cp310-* cp311-*" 19 | 20 | [tool.cibuildwheel.linux] 21 | archs = ["x86_64", "i686", "aarch64"] 22 | -------------------------------------------------------------------------------- /renameat2_build.py: -------------------------------------------------------------------------------- 1 | from cffi import FFI 2 | 3 | ffibuilder = FFI() 4 | 5 | ffibuilder.cdef( # type: ignore 6 | """ 7 | int renameat2(int olddirfd, const char *oldpath, 8 | int newdirfd, const char *newpath, 9 | unsigned int flags); 10 | """ 11 | ) 12 | ffibuilder.set_source( # type: ignore 13 | "renameat2._renameat2", "#include ", sources=["renameat2.c"] 14 | ) 15 | 16 | if __name__ == "__main__": 17 | ffibuilder.compile(verbose=True) # type: ignore 18 | -------------------------------------------------------------------------------- /.github/workflows/bumpr.yml: -------------------------------------------------------------------------------- 1 | name: Bump version 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: 8 | - labeled 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | # Bump version on merging Pull Requests with specific labels. 19 | # (bump:major,bump:minor,bump:patch) 20 | - uses: haya14busa/action-bumpr@v1 21 | with: 22 | github_token: ${{ secrets.BUMPR_TOKEN }} 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 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 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | yaml-files: 2 | - "*.yaml" 3 | - ".*.yaml" 4 | - "*.yml" 5 | - ".*.yml" 6 | 7 | rules: 8 | braces: enable 9 | brackets: enable 10 | colons: enable 11 | commas: enable 12 | comments: 13 | level: warning 14 | comments-indentation: 15 | level: warning 16 | document-end: disable 17 | document-start: disable 18 | empty-lines: enable 19 | empty-values: disable 20 | hyphens: enable 21 | indentation: 22 | spaces: consistent 23 | indent-sequences: whatever 24 | key-duplicates: enable 25 | key-ordering: disable 26 | line-length: disable 27 | new-line-at-end-of-file: enable 28 | new-lines: enable 29 | octal-values: disable 30 | quoted-strings: disable 31 | trailing-spaces: enable 32 | truthy: 33 | level: warning 34 | ignore: .github/workflows 35 | -------------------------------------------------------------------------------- /renameat2.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #ifndef SYS_renameat2 6 | # ifdef __x86_64__ 7 | # define SYS_renameat2 316 8 | # endif 9 | # ifdef __i386__ 10 | # define SYS_renameat2 353 11 | # endif 12 | # ifdef __aarch64__ 13 | # define SYS_renameat2 276 14 | # endif 15 | # ifdef __arm__ 16 | # define SYS_renameat2 382 17 | # endif 18 | #endif 19 | 20 | #ifndef SYS_renameat2 21 | # error SYS_renameat2 is not defined 22 | #endif 23 | 24 | /* Newer versions of glibc include this, but none new enough for manylinux */ 25 | int renameat2(int olddirfd, const char *oldpath, 26 | int newdirfd, const char *newpath, 27 | unsigned int flags) 28 | { 29 | return syscall(SYS_renameat2, olddirfd, oldpath, newdirfd, newpath, flags); 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11.0-rc.1"] 13 | 14 | name: Python ${{ matrix.python-version }} 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Setup python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | architecture: x64 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip wheel 27 | python -m pip install tox tox-gh-actions 28 | 29 | - name: Test with tox 30 | run: | 31 | export PYTHONFAULTHANDLER=1 32 | tox 33 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Atomically swap two files 4 | 5 | ```python 6 | from renameat2 import exchange 7 | 8 | exchange("/tmp/apples", "/tmp/oranges") 9 | ``` 10 | 11 | ## Rename a file 12 | 13 | ```python 14 | from renameat2 import rename 15 | 16 | rename("/tmp/apples", "/tmp/rotten_apples") 17 | ``` 18 | 19 | ## Rename a file, failing if it already exists 20 | 21 | ```python 22 | from renameat2 import rename 23 | from errno import EEXIST 24 | 25 | try: 26 | rename("/tmp/apples", "/tmp/rotten_apples", replace=False) 27 | except OSError as e: 28 | if e.errno == EEXIST: 29 | print("/tmp/rotten_apples exists") 30 | else: 31 | raise 32 | ``` 33 | 34 | ## "Whiteout" a file 35 | 36 | I'm not entirely sure why you'd need to do this, but I felt bad leaving it out of the API. 37 | 38 | ```python 39 | from renameat2 import rename 40 | 41 | renameat2("/tmp/apples", "/tmp/oranges", whiteout=True) 42 | ``` 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jordan Webb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = renameat2 3 | description = A wrapper around Linux's renameat2 system call 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | author = Jordan Webb 7 | author_email = jordan@getseam.com 8 | url = https://github.com/jordemort/python-renameat2 9 | keywords = linux, rename, renameat2, overlayfs, atomic, swap, exchange, whiteout 10 | license = MIT 11 | license_file = LICENSE 12 | classifiers = 13 | Development Status :: 4 - Beta 14 | Intended Audience :: Developers 15 | License :: OSI Approved :: MIT License 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3 :: Only 18 | Programming Language :: Python :: 3.6 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.9 22 | Programming Language :: Python :: 3.10 23 | Programming Language :: Python :: 3.11 24 | Operating System :: POSIX :: Linux 25 | Topic :: Software Development :: Libraries 26 | Topic :: System :: Filesystems 27 | project_urls = 28 | Documentation = https://python-renameat2.readthedocs.io/ 29 | Bug Tracker = https://github.com/jordemort/python-renameat2/issues 30 | Source Code = https://github.com/jordemort/python-renameat2 31 | 32 | [options] 33 | packages = renameat2 34 | python_requires = >= 3.6 35 | setup_requires = 36 | cffi >= 1.0.0 37 | setuptools_scm 38 | install_requires = cffi >= 1.0.0 39 | tests_require = pytest 40 | 41 | [build_sphinx] 42 | project = python-renameat2 43 | source-dir = docs 44 | 45 | [flake8] 46 | max-line-length = 88 47 | extend-ignore = E203, W503 48 | 49 | [tox:tox] 50 | envlist = py37, py38, py39, py310, py311 51 | 52 | [gh-actions] 53 | python = 54 | 3.7: py37 55 | 3.8: py38 56 | 3.9: py39 57 | 3.10: py310 58 | 3.11: py311 59 | 60 | [testenv] 61 | changedir = {envtmpdir} 62 | deps = -r requirements-dev.txt 63 | commands = pytest -v {toxinidir}/tests 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-renameat2 2 | 3 | This is a Python wrapper (using [CFFI](https://cffi.readthedocs.io/en/latest/)) around Linux's [renameat2](https://manpages.debian.org/buster/manpages-dev/renameat.2.en.html) system call. With `renameat2`, you can atomically swap two files, choose if existing files are replaced, and create "whiteout" files for overlay filesystems. 4 | 5 | ## Requirements 6 | 7 | This package requires Python 3.6. I tried building it for Python 3.5 and got some syntax errors with the type declarations. I don't care about Python 3.5 personally so I didn't bother fixing it. If you care about Python 3.5 and want to write a patch to make it work there too, I would consider merging it. 8 | 9 | This package requires Linux, because `renameat2` is a Linux-specific system call. Your kernel must be version 3.15.0 or newer to use `renameat2`; it does not exist in older kernels. Importing this module will raise a *RuntimeError* if you are not running on Linux or if your kernel is older than 3.15.0. 10 | 11 | This package does not have any libc requirements; glibc includes a wrapper for `renameat2` in version 2.28 and newer, but this is significantly newer than the glibc in any of the [manylinux](https://github.com/pypa/manylinux) containers. In order to avoid inflicting any libc requirements on the user, this package brings its own wrapper function that makes the system call directly. 12 | 13 | ## Status 14 | 15 | Stableish? It's just a single system call and I can't imagine doing too much more with the interface. I did use this project to brush about 11 years of dust off of my Python packaging techniques, though, so let me know if you see anything amiss. Pull requests are welcome. 16 | 17 | ## Links 18 | 19 | - [Documentation](https://python-renameat2.readthedocs.io/en/latest/) 20 | - [PyPI](https://pypi.org/project/renameat2/) 21 | - [GitHub repository](https://github.com/jordemort/python-renameat2) 22 | 23 | ## License 24 | 25 | This package is provided under the [MIT License](https://github.com/jordemort/python-renameat2/blob/main/LICENSE). 26 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "29 19 * * 4" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["cpp", "python"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | with: 43 | fetch-depth: 0 44 | 45 | - name: Set up Python 46 | uses: actions/setup-python@v4 47 | with: 48 | python-version: "3.10" 49 | 50 | # Initializes the CodeQL tools for scanning. 51 | - name: Initialize CodeQL 52 | uses: github/codeql-action/init@v2 53 | with: 54 | languages: ${{ matrix.language }} 55 | # If you wish to specify custom queries, you can do so here or in a config file. 56 | # By default, queries listed here will override any specified in a config file. 57 | # Prefix the list here with "+" to use these queries and those in the config file. 58 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 59 | 60 | - name: Build 61 | run: | 62 | python3.10 -m pip install --upgrade pip wheel 63 | python3.10 setup.py build 64 | 65 | - name: Perform CodeQL Analysis 66 | uses: github/codeql-action/analyze@v2 67 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | 4 | name: Lint 5 | 6 | jobs: 7 | shellcheck: 8 | name: shellcheck 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | submodules: false 14 | 15 | - name: shellcheck 16 | if: always() 17 | uses: reviewdog/action-shellcheck@v1.16 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | reporter: github-pr-review 21 | exclude: "./.git/*" 22 | fail_on_error: true 23 | 24 | markdownlint: 25 | name: markdownlint 26 | runs-on: ubuntu-20.04 27 | steps: 28 | - uses: actions/checkout@v3 29 | with: 30 | submodules: false 31 | 32 | - name: markdownlint 33 | uses: reviewdog/action-markdownlint@v0.9 34 | with: 35 | github_token: ${{ secrets.GITHUB_TOKEN }} 36 | reporter: github-pr-review 37 | fail_on_error: true 38 | 39 | misspell: 40 | name: misspell 41 | runs-on: ubuntu-20.04 42 | steps: 43 | - uses: actions/checkout@v3 44 | with: 45 | submodules: false 46 | 47 | - name: misspell 48 | if: always() 49 | uses: reviewdog/action-misspell@v1 50 | with: 51 | reporter: github-pr-review 52 | github_token: ${{ secrets.GITHUB_TOKEN }} 53 | locale: "US" 54 | 55 | yamllint: 56 | name: yamllint 57 | runs-on: ubuntu-20.04 58 | steps: 59 | - uses: actions/checkout@v3 60 | with: 61 | submodules: false 62 | 63 | - name: yamllint 64 | uses: reviewdog/action-yamllint@v1 65 | with: 66 | github_token: ${{ secrets.GITHUB_TOKEN }} 67 | reporter: github-pr-review 68 | 69 | pyflakes: 70 | name: pyflakes 71 | runs-on: ubuntu-20.04 72 | steps: 73 | - uses: actions/checkout@v3 74 | - name: pyflakes 75 | uses: reviewdog/action-pyflakes@v1 76 | with: 77 | github_token: ${{ secrets.GITHUB_TOKEN }} 78 | reporter: github-pr-review 79 | 80 | pyright: 81 | name: pyright 82 | runs-on: ubuntu-20.04 83 | steps: 84 | - uses: actions/checkout@v3 85 | - uses: jordemort/action-pyright@v1 86 | with: 87 | github_token: ${{ secrets.GITHUB_TOKEN }} 88 | reporter: github-pr-review 89 | lib: true 90 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | from importlib_metadata import version 14 | 15 | release = version("renameat2") 16 | version = ".".join(release.split(".")[:2]) 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "python-renameat2" 21 | copyright = "2021, Jordan Webb" 22 | author = "Jordan Webb" 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [ 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.coverage", 33 | "myst_parser", 34 | "sphinx_rtd_theme", 35 | "sphinx.ext.intersphinx", 36 | "sphinx_search.extension", 37 | ] 38 | 39 | # source_suffix = [".rst", ".md"] 40 | intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ["_templates"] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 49 | 50 | 51 | # -- Options for HTML output ------------------------------------------------- 52 | 53 | # The theme to use for HTML and HTML Help pages. See the documentation for 54 | # a list of builtin themes. 55 | # 56 | html_theme = "sphinx_rtd_theme" 57 | 58 | # Add any paths that contain custom static files (such as style sheets) here, 59 | # relative to this directory. They are copied after the builtin static files, 60 | # so a file named "default.css" will overwrite the builtin "default.css". 61 | # html_static_path = ["_static"] 62 | 63 | exclude_patterns = ["README.md"] 64 | 65 | manpages_url = "https://manpages.debian.org/{path}" 66 | -------------------------------------------------------------------------------- /.github/workflows/suggestions.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | 4 | name: Suggestions 5 | 6 | jobs: 7 | shell: 8 | name: Shell suggestions 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | submodules: false 14 | 15 | - uses: actions/setup-go@v3 16 | - run: echo "$HOME/go/bin" >> "$GITHUB_PATH" 17 | - run: GO111MODULE=on go get mvdan.cc/sh/v3/cmd/shfmt 18 | 19 | - name: install shellcheck 20 | run: | 21 | scversion="latest" 22 | wget -qO- "https://github.com/koalaman/shellcheck/releases/download/${scversion?}/shellcheck-${scversion?}.linux.x86_64.tar.xz" | tar -xJv 23 | sudo cp "shellcheck-${scversion}/shellcheck" /usr/local/bin/ 24 | rm -rf "shellcheck-${scversion}/shellcheck" 25 | - run: shellcheck -x -f diff $(shfmt -f .) | patch -p1 26 | - run: shfmt -i 2 -ci -w . 27 | 28 | - name: suggester / shellcheck 29 | uses: reviewdog/action-suggester@v1.6 30 | with: 31 | tool_name: shellcheck / shfmt 32 | 33 | markdown: 34 | name: Markdown suggestions 35 | runs-on: ubuntu-20.04 36 | steps: 37 | - uses: actions/checkout@v3 38 | with: 39 | submodules: false 40 | 41 | - run: sudo npm install -g markdownlint-cli 42 | 43 | - run: markdownlint --fix --ignore site/_includes . || true 44 | 45 | - name: suggester / markdown 46 | uses: reviewdog/action-suggester@v1.6 47 | with: 48 | tool_name: markdownlint-cli 49 | 50 | prettier: 51 | name: Prettier suggestions 52 | runs-on: ubuntu-20.04 53 | steps: 54 | - uses: actions/checkout@v3 55 | with: 56 | submodules: false 57 | 58 | - run: sudo npm install -g prettier 59 | 60 | - run: prettier -u -w '**/*.yaml' '**/.*.yaml' '**/*.yml' '**/.*.yml' '**/*.json' '**/*.md' || true 61 | 62 | - name: suggester / prettier 63 | uses: reviewdog/action-suggester@v1.6 64 | with: 65 | tool_name: prettier 66 | 67 | black: 68 | name: Black formatter 69 | runs-on: ubuntu-20.04 70 | steps: 71 | - uses: actions/checkout@v3 72 | with: 73 | submodules: false 74 | 75 | - name: Check files using the black formatter 76 | uses: rickstaa/action-black@v1.3.0 77 | id: action_black 78 | with: 79 | black_args: "." 80 | 81 | - name: suggester / black 82 | uses: reviewdog/action-suggester@v1.6 83 | with: 84 | tool_name: black 85 | -------------------------------------------------------------------------------- /tests/renameat2_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import tempfile 3 | 4 | from pathlib import Path 5 | 6 | import renameat2 7 | 8 | 9 | def test_exchange(): 10 | with tempfile.TemporaryDirectory() as tmp: 11 | tmp = Path(tmp) 12 | apple_path = tmp.joinpath("apple") 13 | with open(apple_path, "w") as apple_out: 14 | apple_out.write("apple") 15 | 16 | orange_path = tmp.joinpath("orange") 17 | with open(orange_path, "w") as apple_out: 18 | apple_out.write("orange") 19 | 20 | renameat2.exchange(apple_path, orange_path) 21 | 22 | with open(apple_path) as apple_in: 23 | assert apple_in.read() == "orange" 24 | 25 | with open(orange_path) as orange_in: 26 | assert orange_in.read() == "apple" 27 | 28 | 29 | def test_rename_replace(): 30 | with tempfile.TemporaryDirectory() as tmp: 31 | tmp = Path(tmp) 32 | apple_path = tmp.joinpath("apple") 33 | with open(apple_path, "w") as apple_out: 34 | apple_out.write("apple") 35 | 36 | orange_path = tmp.joinpath("orange") 37 | with open(orange_path, "w") as apple_out: 38 | apple_out.write("orange") 39 | 40 | renameat2.rename(apple_path, orange_path, replace=True) 41 | 42 | assert not apple_path.exists() 43 | 44 | with open(orange_path) as orange_in: 45 | assert orange_in.read() == "apple" 46 | 47 | 48 | def test_rename_noreplace(): 49 | with tempfile.TemporaryDirectory() as tmp: 50 | tmp = Path(tmp) 51 | apple_path = tmp.joinpath("apple") 52 | with open(apple_path, "w") as apple_out: 53 | apple_out.write("apple") 54 | 55 | orange_path = tmp.joinpath("orange") 56 | with open(orange_path, "w") as apple_out: 57 | apple_out.write("orange") 58 | 59 | with pytest.raises(OSError): 60 | renameat2.rename(apple_path, orange_path, replace=False) 61 | 62 | assert apple_path.exists() 63 | assert orange_path.exists() 64 | 65 | 66 | def test_rename_whiteout(): 67 | with tempfile.TemporaryDirectory() as tmp: 68 | tmp = Path(tmp) 69 | apple_path = tmp.joinpath("apple") 70 | with open(apple_path, "w") as apple_out: 71 | apple_out.write("apple") 72 | 73 | orange_path = tmp.joinpath("orange") 74 | with open(orange_path, "w") as apple_out: 75 | apple_out.write("orange") 76 | 77 | try: 78 | renameat2.rename(apple_path, orange_path, whiteout=True) 79 | except: 80 | raise RuntimeError(f"apple_path = {apple_path} orange_path = {orange_path}") 81 | 82 | assert apple_path.is_char_device() 83 | 84 | with open(orange_path) as orange_in: 85 | assert orange_in.read() == "apple" 86 | -------------------------------------------------------------------------------- /.github/workflows/build_wheels.yml: -------------------------------------------------------------------------------- 1 | name: Build wheels 2 | 3 | on: 4 | pull_request: 5 | push: 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | # Build the source distribution for PyPI 11 | build_sdist: 12 | name: Build sdist 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: "3.10" 24 | 25 | - name: Build sdist 26 | run: | 27 | python3.10 -m pip install --upgrade wheel 28 | python3.10 setup.py sdist 29 | 30 | - uses: actions/upload-artifact@v3 31 | with: 32 | path: dist/*.tar.gz 33 | 34 | # Build binary distributions for PyPI 35 | build_wheels: 36 | name: Build ${{ matrix.build }} on ${{ matrix.os }} 37 | runs-on: ${{ matrix.os }} 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | os: [ubuntu-latest] 42 | build: [cp311, cp310, cp39, cp38, cp37, cp36] 43 | 44 | steps: 45 | - uses: actions/checkout@v3 46 | with: 47 | fetch-depth: 0 48 | 49 | - name: Set up QEMU 50 | if: runner.os == 'Linux' 51 | uses: docker/setup-qemu-action@v2.1.0 52 | 53 | - name: Build wheels 54 | uses: pypa/cibuildwheel@v2.11.4 55 | env: 56 | CIBW_BUILD: ${{ matrix.build }}-* 57 | 58 | - uses: actions/upload-artifact@v3 59 | with: 60 | path: wheelhouse/renameat2-*.whl 61 | 62 | # Create a GitHub release 63 | github_release: 64 | name: Create GitHub release 65 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 66 | needs: [build_wheels, build_sdist] 67 | runs-on: ubuntu-latest 68 | permissions: 69 | contents: write 70 | 71 | steps: 72 | - uses: actions/checkout@v3 73 | with: 74 | fetch-depth: 0 75 | 76 | - uses: actions/download-artifact@v3 77 | with: 78 | name: artifact 79 | path: dist 80 | 81 | - name: "✏️ Generate release changelog" 82 | id: changelog 83 | uses: heinrichreimer/github-changelog-generator-action@v2.3 84 | with: 85 | filterByMilestone: false 86 | onlyLastTag: true 87 | pullRequests: true 88 | prWoLabels: true 89 | token: ${{ secrets.GITHUB_TOKEN }} 90 | verbose: true 91 | 92 | - name: Create GitHub release 93 | uses: softprops/action-gh-release@v1 94 | with: 95 | body: ${{ steps.changelog.outputs.changelog }} 96 | files: dist/**/* 97 | 98 | # Test PyPI 99 | test_pypi_publish: 100 | name: Test publishing to PyPI 101 | needs: [build_wheels, build_sdist] 102 | runs-on: ubuntu-latest 103 | 104 | steps: 105 | - uses: actions/download-artifact@v3 106 | with: 107 | name: artifact 108 | path: dist 109 | 110 | - uses: pypa/gh-action-pypi-publish@v1.6.4 111 | with: 112 | user: __token__ 113 | password: ${{ secrets.TEST_PYPI_TOKEN }} 114 | repository_url: https://test.pypi.org/legacy/ 115 | skip_existing: true 116 | 117 | # Publish to PyPI 118 | pypi_publish: 119 | name: Publish to PyPI 120 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 121 | needs: [build_wheels, build_sdist] 122 | runs-on: ubuntu-latest 123 | 124 | steps: 125 | - uses: actions/download-artifact@v3 126 | with: 127 | name: artifact 128 | path: dist 129 | 130 | - uses: pypa/gh-action-pypi-publish@v1.6.4 131 | with: 132 | user: __token__ 133 | password: ${{ secrets.PYPI_TOKEN }} 134 | -------------------------------------------------------------------------------- /renameat2/__init__.py: -------------------------------------------------------------------------------- 1 | """renameat2 is a wrapper around Linux's `renameat2` system call. 2 | 3 | The most likely reason you might want to use renameat2 is to atomically swap 4 | two files; the :func:`exchange` function is for you. 5 | 6 | If you just want to rename things with more control than :py:func:`os.rename`, 7 | and/or possibly do some weird overlayfs stuff, check out :func:`rename`. 8 | 9 | Finally, if you really just like the interface of renameat2 as it's implemented 10 | in the system call, :func:`renameat2` recreates it in Python. 11 | """ 12 | 13 | import errno 14 | import os 15 | 16 | from contextlib import contextmanager 17 | from enum import IntFlag 18 | from pathlib import Path 19 | from typing import Union 20 | 21 | from ._renameat2 import lib as _lib, ffi as _ffi 22 | 23 | 24 | def _check_kernel_version(): 25 | uname = os.uname() 26 | 27 | if uname.sysname != "Linux": 28 | raise RuntimeError("renameat2 is Linux-specific") 29 | 30 | kernelver = uname.release.split(".") 31 | 32 | if int(kernelver[0]) < 3: 33 | raise RuntimeError("Kernel 3.15 is required to use renameat2") 34 | elif int(kernelver[0]) == 3 and int(kernelver[1]) < 15: 35 | raise RuntimeError("Kernel 3.15 is required to use renameat2") 36 | 37 | 38 | _check_kernel_version() 39 | 40 | 41 | class Flags(IntFlag): 42 | """Bit flags accepted by the ``flags`` parameter of :func:`renameat2.renameat2`""" 43 | 44 | RENAME_EXCHANGE = 2 45 | """ 46 | Atomically exchange oldpath and newpath. Both pathnames must exist but may be of 47 | different types (e.g., one could be a non-empty directory and the other a symbolic 48 | link). 49 | 50 | RENAME_EXCHANGE can't be used in combination with RENAME_NOREPLACE or 51 | RENAME_WHITEOUT. 52 | """ 53 | 54 | RENAME_NOREPLACE = 1 55 | """ 56 | Don't overwrite newpath of the rename. Return an error if newpath already exists. 57 | 58 | RENAME_NOREPLACE requires support from the underlying filesystem. See the 59 | :manpage:`renameat(2)` manpage for more information. 60 | """ 61 | 62 | RENAME_WHITEOUT = 4 63 | """ 64 | Specifying RENAME_WHITEOUT creates a "whiteout" object at the source of the rename 65 | at the same time as performing the rename. The whole operation is atomic, so that 66 | if the rename succeeds then the whiteout will also have been created. 67 | 68 | This operation makes sense only for overlay/union filesystem implementations. 69 | 70 | See the :manpage:`renameat(2)` man page for more information. 71 | """ 72 | 73 | 74 | def renameat2( 75 | olddirfd: int, oldpath: str, newdirfd: int, newpath: str, flags: Flags = Flags(0) 76 | ) -> None: 77 | """A thin wrapper around the renameat2 C library function. 78 | 79 | Most people will likely prefer the more Pythonic interfaces provided 80 | by the :func:`rename` or :func:`exchange` wrapper functions; this one is 81 | for people who prefer their C library bindings without any sugar. 82 | 83 | :param olddirfd: A directory file descriptor 84 | :type olddirfd: int 85 | :param oldpath: The name of a file in the directory represented by olddirfd 86 | :type oldpath: str 87 | :param newdirfd: A directory file descriptor 88 | :type newdirfd: int 89 | :param newpath: The name of a file in the in the directory represented by newdirfd 90 | :type newpath: str 91 | :param flags: A bit mask consisting of zero or more of :data:`RENAME_EXCHANGE`, 92 | :data:`RENAME_NOREPLACE`, or :data:`RENAME_WHITEOUT`. 93 | :type flags: Flags 94 | 95 | :raises OSError: if the system call fails 96 | """ 97 | err: int = _lib.renameat2( # type: ignore 98 | olddirfd, oldpath.encode(), newdirfd, newpath.encode(), flags 99 | ) 100 | 101 | if err != 0: 102 | raise OSError( 103 | _ffi.errno, f"renameat2: {errno.errorcode[_ffi.errno]}" # type: ignore 104 | ) 105 | 106 | 107 | @contextmanager 108 | def _split_dirfd(path: Union[Path, str]): 109 | path = Path(path) 110 | fd = os.open(path.parent, os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC) 111 | try: 112 | yield (fd, path.name) 113 | finally: 114 | os.close(fd) 115 | 116 | 117 | def rename( 118 | oldpath: Union[Path, str], 119 | newpath: Union[Path, str], 120 | replace: bool = True, 121 | whiteout: bool = False, 122 | ) -> None: 123 | """Rename a file using the renameat2 system call. 124 | 125 | :param oldpath: Path to the file to rename 126 | :type oldpath: Union[pathlib.Path, str] 127 | :param newpath: Path to rename the file to 128 | :type newpath: Union[pathlib.Path, str] 129 | :param replace: If true, any existing file at newpath will be replaced. 130 | If false, any existing file at newpath will cause an error to be raised. 131 | False corresponds to passing RENAME_NOREPLACE to the system call. 132 | :type replace: bool 133 | :param whiteout: If true, a "whiteout" file will be left behind at oldpath. 134 | True corresponds to passing RENAME_WHITEOUT to the system call. 135 | :type whiteout: bool 136 | 137 | :raises OSError: if the system call fails 138 | """ 139 | flags = Flags(0) 140 | if not replace: 141 | flags |= Flags.RENAME_NOREPLACE 142 | 143 | if whiteout: 144 | flags |= Flags.RENAME_WHITEOUT 145 | 146 | with _split_dirfd(oldpath) as (dirfd_a, name_a): 147 | with _split_dirfd(newpath) as (dirfd_b, name_b): 148 | renameat2(dirfd_a, name_a, dirfd_b, name_b, flags) 149 | 150 | 151 | def exchange(a: Union[Path, str], b: Union[Path, str]) -> None: 152 | """Atomically swap two files. 153 | 154 | This is probably the main attraction of this module. 155 | 156 | After this call, the file originally referred to by the first path 157 | will be referred to by the second, and the file originally referred 158 | to by the second path will be referred to by the first. 159 | 160 | This is an atomic operation; that is to say, there is no possible 161 | intermediate state where the files could be "partially" swapped; 162 | either the call succeeds and the files are exchanged, or the call 163 | fails and the files are not exchanged. 164 | 165 | This function is implemented by passing RENAME_EXCHANGE to the system call. 166 | 167 | :param a: Path to a file 168 | :type a: Union[pathlib.Path, str] 169 | :param b: Path to a file 170 | :type b: Union[pathlib.Path, str] 171 | 172 | :raises OSError: if `a` and `b` cannot be swapped 173 | """ 174 | with _split_dirfd(a) as (dirfd_a, name_a): 175 | with _split_dirfd(b) as (dirfd_b, name_b): 176 | renameat2(dirfd_a, name_a, dirfd_b, name_b, Flags.RENAME_EXCHANGE) 177 | --------------------------------------------------------------------------------