├── .dockerignore ├── .github └── workflows │ ├── docs.yml │ ├── release.yml │ ├── style.yml │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docs ├── changelog.md ├── conf.py ├── contributing.md ├── index.md └── reference.rst ├── pyproject.toml ├── src └── pyforce │ ├── __init__.py │ ├── commands.py │ ├── exceptions.py │ ├── models.py │ ├── py.typed │ └── utils.py └── tests ├── conftest.py └── test_commands.py /.dockerignore: -------------------------------------------------------------------------------- 1 | /.venv 2 | __pycache__ 3 | *.egg-info 4 | *.pyc 5 | /dist 6 | /.mypy_cache 7 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Test build docs 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.12" 18 | cache: pip 19 | - name: install requirements 20 | run: | 21 | python -im pip install --upgrade pip 22 | python -im pip install .[docs] 23 | - name: Build docs 24 | run: | 25 | python -m sphinx -W -n -b html -a docs docs/_build 26 | 27 | linkcheck: 28 | name: Linkcheck 29 | runs-on: ubuntu-22.04 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-python@v5 33 | with: 34 | python-version: "3.12" 35 | cache: "pip" 36 | - name: install requirements 37 | run: | 38 | python -im pip install --upgrade pip 39 | python -im pip install .[docs] 40 | - name: Linkcheck docs 41 | run: python -m sphinx -b linkcheck -a docs docs/_build/linkcheck 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: ["*"] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | build: 12 | name: Build package 13 | runs-on: ubuntu-latest 14 | outputs: 15 | version: ${{ steps.build.outputs.version }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-python@v5 21 | with: 22 | python-version: "3.12" 23 | cache: pip 24 | - name: Install requirements 25 | run: | 26 | python -Im pip install --upgrade pip 27 | python -Im pip install build check-wheel-contents hatch hatch-vcs 28 | - name: Build package 29 | id: build 30 | run: | 31 | python -Im build 32 | echo "version=$(python -m hatchling version)" | tee -a "$GITHUB_OUTPUT" 33 | - name: Check wheel content 34 | run: check-wheel-contents dist/*.whl 35 | - name: Print package contents summary 36 | run: | 37 | echo -e '
SDist Contents\n' >> $GITHUB_STEP_SUMMARY 38 | tar -tf dist/*.tar.gz | tree -a --fromfile . | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY 39 | echo -e '
\n' >> $GITHUB_STEP_SUMMARY 40 | echo -e '
Wheel Contents\n' >> $GITHUB_STEP_SUMMARY 41 | unzip -Z1 dist/*.whl | tree -a --fromfile . | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY 42 | echo -e '
\n' >> $GITHUB_STEP_SUMMARY 43 | - uses: actions/upload-artifact@v4 44 | with: 45 | name: dist 46 | path: ./dist 47 | 48 | # Draft a new release on tagged commit on main. 49 | draft-release: 50 | name: Draft release 51 | runs-on: ubuntu-latest 52 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 53 | needs: [build] 54 | permissions: 55 | contents: write 56 | steps: 57 | - uses: actions/download-artifact@v4 58 | - name: Draft a new release 59 | run: > 60 | gh release create --draft --repo ${{ github.repository }} 61 | ${{ github.ref_name }} 62 | dist/* 63 | env: 64 | GH_TOKEN: ${{ github.token }} 65 | 66 | # Publish to PyPI on tagged commit on main. 67 | publish-pypi: 68 | name: Publish to PyPI 69 | runs-on: ubuntu-latest 70 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 71 | needs: [build] 72 | environment: 73 | name: publish-pypi 74 | url: https://pypi.org/project/pyforce-p4/${{ needs.build.outputs.version }} 75 | permissions: 76 | id-token: write 77 | steps: 78 | - uses: actions/download-artifact@v4 79 | - name: Upload package to PyPI 80 | uses: pypa/gh-action-pypi-publish@release/v1 81 | 82 | # Publish to Test PyPI on every commit on main. 83 | publish-test-pypi: 84 | name: Publish to Test PyPI 85 | runs-on: ubuntu-latest 86 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 87 | needs: [build] 88 | environment: 89 | name: publish-test-pypi 90 | url: https://test.pypi.org/project/pyforce-p4/${{ needs.build.outputs.version }} 91 | permissions: 92 | id-token: write 93 | steps: 94 | - uses: actions/download-artifact@v4 95 | - name: Upload package to Test PyPI 96 | uses: pypa/gh-action-pypi-publish@release/v1 97 | with: 98 | repository-url: https://test.pypi.org/legacy/ 99 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Code style 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - "docs/**" 8 | pull_request: 9 | branches: [main] 10 | paths-ignore: 11 | - "docs/**" 12 | 13 | # Cancel concurent in-progress jobs or run on pull_request 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | ruff-lint: 20 | name: Ruff lint 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.12" 27 | cache: pip 28 | - name: Install requirements 29 | run: | 30 | python -Im pip install --upgrade pip 31 | python -Im pip install ruff 32 | - name: Run Ruff linter 33 | run: python -Im ruff check --output-format=github src tests 34 | 35 | ruff-format: 36 | name: Ruff format diff 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-python@v5 41 | with: 42 | python-version: "3.12" 43 | cache: pip 44 | - name: Install requirements 45 | run: | 46 | python -Im pip install --upgrade pip 47 | python -Im pip install ruff 48 | - name: Run Ruff formatter 49 | run: python -Im ruff format --diff src tests 50 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests & Mypy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - "docs/**" 8 | pull_request: 9 | branches: [main] 10 | paths-ignore: 11 | - "docs/**" 12 | 13 | jobs: 14 | mypy: 15 | name: Mypy ${{ matrix.python-version }} 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: 20 | - "3.8" 21 | - "3.9" 22 | - "3.10" 23 | - "3.11" 24 | - "3.12" 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | cache: pip 31 | - name: Install requirements 32 | run: | 33 | python -Im pip install --upgrade pip 34 | python -Im pip install .[mypy] 35 | - name: Run mypy 36 | run: | 37 | python -Im mypy src tests 38 | 39 | tests: 40 | name: Tests ${{ matrix.python-version }} 41 | runs-on: ubuntu-latest 42 | strategy: 43 | matrix: 44 | python-version: 45 | - "3.8" 46 | - "3.9" 47 | - "3.10" 48 | - "3.11" 49 | - "3.12" 50 | steps: 51 | - uses: actions/checkout@v4 52 | - name: Build image 53 | run: docker build --build-arg="PYTHON_VERSION=${{ matrix.python-version }}" -t local . 54 | - name: Run tests 55 | run: | 56 | docker run --name test local "python -m coverage run -p -m pytest && mkdir cov && mv .coverage.* cov" 57 | docker cp test:/app/cov/. . 58 | - name: Upload coverage data 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: coverage-data-${{ matrix.python-version }} 62 | path: .coverage.* 63 | if-no-files-found: ignore 64 | 65 | coverage: 66 | name: Combine and report coverage 67 | runs-on: ubuntu-latest 68 | needs: tests 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: actions/setup-python@v5 72 | with: 73 | python-version: "3.12" 74 | cache: pip 75 | - name: Download coverage data 76 | uses: actions/download-artifact@v4 77 | with: 78 | pattern: coverage-data-* 79 | merge-multiple: true 80 | - name: Install requirements 81 | run: | 82 | python -Im pip install --upgrade pip 83 | python -Im pip install coverage 84 | - name: Combine and report 85 | run: | 86 | python -Im coverage combine 87 | # Report in summary 88 | python -Im coverage report --show-missing --skip-covered --skip-empty --format=markdown >> $GITHUB_STEP_SUMMARY 89 | # Report in console 90 | python -Im coverage report --show-missing --skip-covered --skip-empty 91 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.venv 2 | __pycache__ 3 | *.egg-info 4 | /.python-version 5 | *.pyc 6 | /dist 7 | /docs/_build 8 | /.coverage 9 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.readthedocs.io/en/stable/config-file/v2.html 2 | version: 2 3 | 4 | build: 5 | os: ubuntu-22.04 6 | tools: 7 | python: "3.12" 8 | 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | python: 13 | install: 14 | - method: pip 15 | path: . 16 | extra_requirements: 17 | - docs 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.1 - 2024-03-31 4 | 5 | - Added `py.typed` file. 6 | - Documentation available at [pyforce.readthedocs.io](https://pyforce.readthedocs.io/latest). 7 | 8 | ## 0.1.0 - 2024-03-30 9 | 10 | - Initial release. 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Makefile commands 4 | 5 | This project include a [Makefile](https://www.gnu.org/software/make/) 6 | containing the most common commands used when developing. 7 | 8 | ```text 9 | $ make help 10 | build Build project 11 | clean Clear local caches and build artifacts 12 | doc Build documentation 13 | formatdiff Show what the formatting would look like 14 | help Display this message 15 | install Install the package and dependencies for local development 16 | interactive Run an interactive docker container 17 | linkcheck Check all external links in docs for integrity 18 | lint Run linter 19 | mypy Perform type-checking 20 | serve Serve documentation at http://127.0.0.1:8000 21 | tests Run the tests with coverage in a docker container 22 | uninstall Remove development environment 23 | ``` 24 | 25 | ## Running the tests 26 | 27 | The tests require a new `p4d` server running in the backgound. 28 | To simplify the development, 29 | tests are runned in a docker container 30 | generated from the [Dockerfile](https://github.com/tahv/pyforce/blob/main/Dockerfile) 31 | at the root of this repo. 32 | 33 | The `make tests` target build the container and run the tests, with coverage, inside. 34 | 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Installation: 2 | # https://www.perforce.com/manuals/p4sag/Content/P4SAG/install.linux.packages.install.html 3 | 4 | # Post-installation configuration: 5 | # https://www.perforce.com/manuals/p4sag/Content/P4SAG/install.linux.packages.configure.html 6 | 7 | # Example perforce server: 8 | # https://github.com/ambakshi/docker-perforce/tree/master/perforce-server 9 | 10 | # Dockerfile from sourcegraph: 11 | # https://github.com/sourcegraph/helix-docker/tree/main 12 | 13 | # Making a Perforce Server with Docker compose 14 | # https://aricodes.net/posts/perforce-server-with-docker/ 15 | ARG PYTHON_VERSION=3.11 16 | FROM python:$PYTHON_VERSION 17 | 18 | # Update Ubuntu and add Perforce Package Source 19 | # Add the perforce public key to our keyring 20 | # Add perforce repository to our APT config 21 | RUN apt-get update && \ 22 | apt-get install -y wget gnupg2 && \ 23 | wget -qO - https://package.perforce.com/perforce.pubkey | apt-key add - && \ 24 | echo "deb http://package.perforce.com/apt/ubuntu focal release" > /etc/apt/sources.list.d/perforce.list && \ 25 | apt-get update 26 | 27 | # Install helix-p4d, which installs p4d, p4, p4dctl, and a configuration script. 28 | RUN apt-get update && apt-get install -y helix-p4d 29 | 30 | WORKDIR /app 31 | 32 | RUN python -m pip install pytest coverage 33 | 34 | COPY . . 35 | 36 | RUN python -m pip install --editable . 37 | 38 | ENTRYPOINT ["/bin/sh", "-c"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Thibaud Gambier 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | venv = .venv 2 | sources = src tests 3 | 4 | DOCS_BUILDDIR = docs/_build 5 | 6 | ifeq ($(OS), Windows_NT) 7 | python = $(venv)\Scripts\python.exe 8 | else 9 | python = $(venv)/bin/python 10 | endif 11 | 12 | .PHONY: uninstall ## Remove development environment 13 | ifeq ($(OS), Windows_NT) 14 | uninstall: 15 | if exist $(venv) rd /q /s $(venv) 16 | for /d /r %%g in (src\*.egg-info) do rd /q /s "%%g" 2>nul || break 17 | else 18 | uninstall: 19 | rm -rf $(venv) 20 | rm -rf src/*.egg-info 21 | endif 22 | 23 | ifeq ($(OS), Windows_NT) 24 | $(venv): pyproject.toml 25 | @$(MAKE) --no-print-directory uninstall 26 | python -m venv $(venv) 27 | $(python) -m pip install build --editable .[dev] 28 | else 29 | $(venv): pyproject.toml 30 | @$(MAKE) --no-print-directory uninstall 31 | python -m venv $(venv) 32 | $(python) -m pip install build --editable .[dev] 33 | touch $(venv) 34 | endif 35 | 36 | .PHONY: install ## Install the package and dependencies for local development 37 | install: 38 | @$(MAKE) --no-print-directory --always-make $(venv) 39 | 40 | .PHONY: clean ## Clear local caches and build artifacts 41 | ifeq ($(OS), Windows_NT) 42 | clean: 43 | del /f /q /s *.pyc >nul 2>nul 44 | del /f /q .coverage >nul 2>nul 45 | del /f /q .coverage.* >nul 2>nul 46 | del /f /q result.xml >nul 2>nul 47 | del /f /q coverage.xml >nul 2>nul 48 | rd /q /s dist 2>nul || break 49 | rd /q /s .ruff_cache 2>nul || break 50 | for /d /r %%g in (__pycache__) do rd /q /s "%%g" 2>nul || break 51 | else 52 | clean: 53 | rm -rf `find . -type f -name '*.pyc'` 54 | rm -f .coverage 55 | rm -f .coverage.* 56 | rm -f result.xml 57 | rm -f coverage.xml 58 | rm -rf dist 59 | rm -rf .ruff_cache 60 | rm -rf `find . -name __pycache__` 61 | rm -rf $(DOCS_BUILDDIR) 62 | endif 63 | 64 | .PHONY: tests ## Run the tests with coverage in a docker container 65 | tests: $(venv) 66 | -docker stop pyforce-test && docker rm pyforce-test 67 | docker build --quiet --platform linux/amd64 -t pyforce . 68 | docker run --name pyforce-test pyforce "python -m coverage run -m pytest" 69 | docker cp pyforce-test:/app/.coverage .coverage 70 | $(python) -m coverage report --show-missing --skip-covered --skip-empty 71 | 72 | .PHONY: interactive ## Run an interactive docker container 73 | interactive: 74 | docker build --platform linux/amd64 -t pyforce . 75 | docker run --rm -it pyforce -c "mkdir -p /depot && p4d -r /depot -r localhost:1666 --daemonsafe -L /app/p4dlogs && /bin/sh" 76 | 77 | .PHONY: lint ## Run linter 78 | lint: $(venv) 79 | $(python) -m ruff check $(sources) 80 | 81 | .PHONY: formatdiff ## Show what the formatting would look like 82 | formatdiff: $(venv) 83 | $(python) -m ruff format --diff $(sources) 84 | 85 | .PHONY: mypy ## Perform type-checking 86 | mypy: $(venv) 87 | $(python) -m mypy $(sources) 88 | 89 | .PHONY: build ## Build project 90 | build: $(venv) 91 | $(python) -m build 92 | 93 | .PHONY: docs ## Build documentation 94 | docs: $(venv) 95 | $(python) -m sphinx -W -n -b html -a docs $(DOCS_BUILDDIR) 96 | 97 | .PHONY: serve ## Serve documentation at http://127.0.0.1:8000 98 | serve: $(venv) 99 | $(python) -m sphinx_autobuild -b html -a --watch README.md --watch src -vvv docs $(DOCS_BUILDDIR) 100 | 101 | .PHONY: linkcheck ## Check all external links in docs for integrity 102 | linkcheck: $(venv) 103 | $(python) -m sphinx -b linkcheck -a docs $(DOCS_BUILDDIR)/linkcheck 104 | 105 | .PHONY: help ## Display this message 106 | ifeq ($(OS), Windows_NT) 107 | help: 108 | @setlocal EnableDelayedExpansion \ 109 | && for /f "tokens=2,* delims= " %%g in ('findstr /R /C:"^\.PHONY: .* ##.*$$" Makefile') do \ 110 | set name=%%g && set "name=!name! " && set "name=!name:~0,14!" \ 111 | && set desc=%%h && set "desc=!desc:~3!" \ 112 | && echo !name!!desc! 113 | else 114 | help: 115 | @grep -E '^.PHONY: .*?## .*$$' Makefile \ 116 | | awk 'BEGIN {FS = ".PHONY: |## "}; {printf "%-14s %s\n", $$2, $$3}' \ 117 | | sort 118 | endif 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pyforce 2 | 3 | [![License - MIT][license-badge]][pyforce-license] 4 | [![PyPI - Python Version][python-version-badge]][pyforce-pypi] 5 | [![PyPI - Version][version-badge]][pyforce-pypi] 6 | [![Linter - Ruff][ruff-badge]][ruff-repo] 7 | [![Types - Mypy][mypy-badge]][mypy-repo] 8 | [![CI - Tests][pyforce-workflow-tests-badge]][pyforce-workflow-tests] 9 | [![Documentation Status][pyforce-docs-badge]][pyforce-documentation] 10 | 11 | Python wrapper for Perforce p4 command-line client. 12 | 13 | ## Features 14 | 15 | - Python wrapper for the `p4` command using [marshal](https://docs.python.org/3/library/marshal.html). 16 | - Built with [Pydantic](https://github.com/pydantic/pydantic). 17 | - Fully typed. 18 | - Built for scripting. 19 | 20 | ## Installation 21 | 22 | ```bash 23 | python -m pip install pyforce-p4 24 | ``` 25 | 26 | ## Quickstart 27 | 28 | ```python 29 | import pyforce 30 | 31 | connection = pyforce.Connection(host="localhost:1666", user="foo", client="my-client") 32 | 33 | # Create a new file in our client 34 | file = "/home/foo/my-client/bar.txt" 35 | fp = open(file, "w") 36 | fp.write("bar") 37 | fp.close() 38 | 39 | # Run 'p4 add', open our file for addition to the depot 40 | _, infos = pyforce.add(connection, [file]) 41 | print(infos[0]) 42 | """ 43 | ActionInfo( 44 | action='add', 45 | client_file='/home/foo/my-client/bar.txt', 46 | depot_file='//my-depot/my-stream/bar.txt', 47 | file_type='text', 48 | work_rev=1 49 | ) 50 | """ 51 | 52 | # Run 'p4 submit', submitting our local file 53 | pyforce.p4(connection, ["submit", "-d", "Added bar.txt", file]) 54 | 55 | # Run 'p4 fstat', listing all files in depot 56 | fstats = list(pyforce.fstat(connection, ["//..."])) 57 | print(fstats[0]) 58 | """ 59 | FStat( 60 | client_file='/home/foo/my-client/bar.txt', 61 | depot_file='//my-depot/my-stream/bar.txt', 62 | head=HeadInfo( 63 | action=, 64 | change=2, 65 | revision=1, 66 | file_type='text', 67 | time=datetime.datetime(2024, 3, 29, 13, 56, 57, tzinfo=datetime.timezone.utc), 68 | mod_time=datetime.datetime(2024, 3, 29, 13, 56, 11, tzinfo=datetime.timezone.utc) 69 | ), 70 | have_rev=1, 71 | is_mapped=True, 72 | others_open=None 73 | ) 74 | """ 75 | ``` 76 | 77 | Pyforce has functions for the most common `p4` commands 78 | but can execute more complexe commands with `pyforce.p4`. 79 | 80 | For example, pyforce doesn't have a function to create a new client workspace, 81 | here is how to create one using `pyforce.p4`. 82 | 83 | ```python 84 | import pyforce 85 | 86 | connection = pyforce.Connection(port="localhost:1666", user="foo") 87 | 88 | # Create client 89 | command = ["client", "-o", "-S", "//my-depot/my-stream", "my-client"] 90 | data = pyforce.p4(connection, command)[0] 91 | data["Root"] = "/home/foo/my-client" 92 | pyforce.p4(connection, ["client", "-i"], stdin=data) 93 | 94 | # Get created client 95 | client = pyforce.get_client(connection, "my-client") 96 | print(client) 97 | """ 98 | Client( 99 | name='my-client', 100 | host='5bb1735f73fc', 101 | owner='foo', 102 | root=PosixPath('/home/foo/my-client'), 103 | stream='//my-depot/my-stream', 104 | type=, 105 | views=[View(left='//my-depot/my-stream/...', right='//my-client/...')] 106 | ) 107 | """ 108 | ``` 109 | 110 | ## Documentation 111 | 112 | See pyforce [documentation](https://pyforce.readthedocs.io/latest) for more details. 113 | 114 | ## Contributing 115 | 116 | For guidance on setting up a development environment and contributing to pyforce, 117 | see the [Contributing](https://pyforce.readthedocs.io/latest/contributing.html) section. 118 | 119 | 120 | 121 | [license-badge]: https://img.shields.io/github/license/tahv/pyforce?label=License 122 | [ruff-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json 123 | [version-badge]: https://img.shields.io/pypi/v/pyforce-p4?logo=pypi&label=PyPI&logoColor=white 124 | [python-version-badge]: https://img.shields.io/pypi/pyversions/pyforce-p4?logo=python&label=Python&logoColor=white 125 | [mypy-badge]: https://img.shields.io/badge/Types-Mypy-blue.svg 126 | [pyforce-workflow-tests-badge]: https://github.com/tahv/pyforce/actions/workflows/tests.yml/badge.svg 127 | [pyforce-docs-badge]: https://readthedocs.org/projects/pyforce/badge/?version=latest 128 | 129 | [pyforce-license]: https://github.com/tahv/pyforce/blob/main/LICENSE 130 | [ruff-repo]: https://github.com/astral-sh/ruff 131 | [pyforce-pypi]: https://pypi.org/project/pyforce-p4 132 | [mypy-repo]: https://github.com/python/mypy 133 | [pyforce-workflow-tests]: https://github.com/tahv/pyforce/actions/workflows/tests.yml 134 | [pyforce-documentation]: https://pyforce.readthedocs.io/latest 135 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder. 2 | 3 | Documentation: 4 | https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | """ 6 | 7 | import importlib.metadata 8 | import sys 9 | from pathlib import Path 10 | 11 | PROJECT_ROOT_DIR = Path(__file__).parents[1].resolve() 12 | SRC_DIR = (PROJECT_ROOT_DIR / "src").resolve() 13 | sys.path.append(str(SRC_DIR)) 14 | 15 | # -- Project information ----------------------------------------------------- 16 | 17 | project = "Pyforce" 18 | author = "Thibaud Gambier" 19 | copyright = f"2024, {author}" # noqa: A001 20 | release = importlib.metadata.version("pyforce-p4") 21 | version = ".".join(release.split(".", 2)[0:2]) 22 | 23 | # -- General configuration --------------------------------------------------- 24 | 25 | # fmt: off 26 | extensions = [ 27 | "myst_parser", # markdown 28 | "sphinx.ext.autodoc", # docstring 29 | "sphinxcontrib.autodoc_pydantic", # docstring / pydantic compatibility 30 | "sphinx.ext.napoleon", # google style docstring 31 | "sphinx.ext.intersphinx", # cross-projects references 32 | "enum_tools.autoenum", 33 | ] 34 | # fmt: on 35 | 36 | templates_path = ["_templates"] 37 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 38 | maximum_signature_line_length = 80 39 | default_role = "any" 40 | 41 | # -- Extensions configuration ------------------------------------------------ 42 | 43 | intersphinx_mapping = { 44 | "python": ("https://docs.python.org/3", None), 45 | } 46 | 47 | autodoc_pydantic_model_show_json = False 48 | autodoc_pydantic_model_show_config_summary = False 49 | autodoc_pydantic_model_show_validator_summary = False 50 | autodoc_pydantic_model_show_field_summary = False 51 | autodoc_pydantic_field_show_constraints = False 52 | autodoc_pydantic_field_list_validators = False 53 | 54 | autodoc_pydantic_field_show_default = False 55 | autodoc_pydantic_field_show_required = False 56 | 57 | autodoc_class_signature = "separated" 58 | autodoc_default_options = { 59 | "exclude-members": "__new__", 60 | } 61 | 62 | # -- Options for HTML output ------------------------------------------------- 63 | 64 | html_theme = "furo" 65 | # html_static_path = ["_static"] 66 | html_title = "Pyforce" 67 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CONTRIBUTING.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | ``` 3 | 4 | ```{toctree} 5 | :maxdepth: 2 6 | :hidden: 7 | 8 | self 9 | reference 10 | changelog 11 | contributing 12 | ``` 13 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. _p4 user: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_user.html 2 | .. _p4 client: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_client.html 3 | .. _p4 change: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_change.html 4 | .. _p4 add: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_add.html 5 | .. _p4 filelog: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_filelog.html 6 | .. _p4 fstat: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_fstat.html 7 | .. _p4 edit: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_edit.html 8 | .. _p4 delete: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_delete.html 9 | .. _p4 changes: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_changes.html 10 | .. _File specifications: https://www.perforce.com/manuals/cmdref/Content/CmdRef/filespecs.html 11 | .. _View specification: https://www.perforce.com/manuals/cmdref/Content/CmdRef/views.html 12 | 13 | 14 | API Reference 15 | =============== 16 | 17 | Commands 18 | -------- 19 | 20 | .. autofunction:: pyforce.p4 21 | .. autofunction:: pyforce.login 22 | 23 | .. autofunction:: pyforce.get_user 24 | .. autofunction:: pyforce.get_client 25 | .. autofunction:: pyforce.get_change 26 | .. autofunction:: pyforce.get_revisions 27 | .. autofunction:: pyforce.create_changelist 28 | .. autofunction:: pyforce.add 29 | .. autofunction:: pyforce.edit 30 | .. autofunction:: pyforce.delete 31 | .. autofunction:: pyforce.sync 32 | .. autofunction:: pyforce.fstat 33 | 34 | User 35 | ---- 36 | 37 | .. autopydantic_model:: pyforce.User 38 | 39 | .. autoenum:: pyforce.UserType 40 | :members: 41 | 42 | .. autoenum:: pyforce.AuthMethod 43 | :members: 44 | 45 | Client 46 | ------ 47 | 48 | .. autopydantic_model:: pyforce.Client 49 | 50 | .. autoclass:: pyforce.View 51 | :members: 52 | 53 | .. autoclass:: pyforce.ClientOptions 54 | :members: 55 | 56 | .. autoenum:: pyforce.ClientType 57 | :members: 58 | 59 | .. autoenum:: pyforce.SubmitOptions 60 | :members: 61 | 62 | 63 | Change 64 | ------ 65 | 66 | .. autopydantic_model:: pyforce.Change 67 | .. autopydantic_model:: pyforce.ChangeInfo 68 | 69 | .. autoenum:: pyforce.ChangeType 70 | :members: 71 | 72 | .. autoenum:: pyforce.ChangeStatus 73 | :members: 74 | 75 | 76 | Action 77 | ------ 78 | 79 | .. autopydantic_model:: pyforce.ActionInfo 80 | 81 | .. autoenum:: pyforce.Action 82 | :members: 83 | 84 | .. autoclass:: pyforce.ActionMessage 85 | :members: 86 | 87 | Revision 88 | -------- 89 | 90 | .. autopydantic_model:: pyforce.Revision 91 | 92 | Sync 93 | ---- 94 | 95 | .. autopydantic_model:: pyforce.Sync 96 | 97 | FStat 98 | ----- 99 | 100 | .. autopydantic_model:: pyforce.FStat 101 | .. autopydantic_model:: pyforce.HeadInfo 102 | .. autoclass:: pyforce.OtherOpen 103 | 104 | Exceptions 105 | ---------- 106 | 107 | .. autoexception:: pyforce.AuthenticationError 108 | .. autoexception:: pyforce.ConnectionExpiredError 109 | .. autoexception:: pyforce.CommandExecutionError 110 | .. autoexception:: pyforce.ChangeUnknownError 111 | 112 | Other 113 | ----- 114 | 115 | .. autoclass:: pyforce.Connection 116 | :members: 117 | 118 | .. autoenum:: pyforce.MessageSeverity 119 | :members: 120 | 121 | .. autoenum:: pyforce.MarshalCode 122 | :members: 123 | 124 | .. autoenum:: pyforce.MessageLevel 125 | :members: 126 | 127 | .. class:: pyforce.utils.PerforceDateTime 128 | 129 | Alias of `datetime.datetime` 130 | 131 | .. class:: pyforce.utils.PerforceTimestamp 132 | 133 | Alias of `datetime.datetime` 134 | 135 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | #:schema https://json.schemastore.org/pyproject.json 2 | [build-system] 3 | requires = ["hatchling", "hatch-vcs"] 4 | build-backend = "hatchling.build" 5 | 6 | [project] 7 | name = "pyforce-p4" 8 | description = "Python wrapper for Perforce p4 command-line client" 9 | readme = "README.md" 10 | license = "MIT" 11 | authors = [{ name = "Thibaud Gambier" }] 12 | requires-python = ">=3.7" 13 | dependencies = ["pydantic", "typing_extensions"] 14 | dynamic = ["version"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: Implementation :: CPython", 25 | "Typing :: Typed", 26 | ] 27 | 28 | [project.urls] 29 | Github = "https://github.com/tahv/pyforce" 30 | Changelog = "https://pyforce.readthedocs.io/latest/changelog.html" 31 | Documentation = "https://pyforce.readthedocs.io/latest" 32 | 33 | [project.optional-dependencies] 34 | tests = ["pytest", "coverage"] 35 | style = ["ruff"] 36 | mypy = ["mypy", "pytest"] 37 | docs = [ 38 | "sphinx", 39 | "sphinx-autobuild", 40 | "furo", 41 | "myst-parser", 42 | "autodoc_pydantic", 43 | "enum_tools[sphinx]", 44 | ] 45 | dev = ["pyforce-p4[tests,style,mypy,docs]"] 46 | 47 | [tool.hatch.version] 48 | source = "vcs" 49 | 50 | [tool.hatch.version.raw-options] 51 | local_scheme = "no-local-version" 52 | 53 | [tool.hatch.build.targets.wheel] 54 | packages = ["src/pyforce"] 55 | 56 | [tool.pytest.ini_options] 57 | addopts = "--doctest-modules" 58 | testpaths = ["src", "tests"] 59 | 60 | [tool.coverage.run] 61 | source = ["src/"] 62 | branch = true 63 | 64 | [tool.coverage.report] 65 | show_missing = true 66 | skip_covered = true 67 | exclude_lines = [ 68 | "# pragma: no cover", 69 | "if (False|0|TYPE_CHECKING):", 70 | "if __name__ == ['\"]__main__['\"]:", 71 | ] 72 | 73 | [tool.coverage.paths] 74 | source = ["src/", "*/src"] 75 | 76 | [tool.mypy] 77 | plugins = ["pydantic.mypy"] 78 | disallow_untyped_defs = true 79 | check_untyped_defs = true 80 | disallow_any_unimported = true 81 | no_implicit_optional = true 82 | warn_return_any = true 83 | warn_unused_ignores = true 84 | warn_redundant_casts = true 85 | show_error_codes = true 86 | # disallow_any_generics = true 87 | # implicit_reexport = false 88 | 89 | [tool.pydantic-mypy] 90 | init_typed = true 91 | warn_required_dynamic_aliases = true 92 | 93 | [tool.ruff] 94 | src = ["src", "tests"] 95 | 96 | [tool.ruff.lint] 97 | select = ["ALL"] 98 | ignore = [ 99 | # ANN101: Missing type annotation for `self` in method 100 | "ANN101", 101 | # ANN102: Missing type annotation for `cls` in classmethod 102 | "ANN102", 103 | # D107: Missing docstring in `__init__` 104 | "D107", 105 | # D105: Missing docstring in magic method 106 | "D105", 107 | # S603: `subprocess` call: check for execution of untrusted input 108 | "S603", 109 | # TD002: Missing author in TODO 110 | "TD002", 111 | # TD003: Missing issue link on the line following this TODO 112 | "TD003", 113 | # FIX002: Line contains TODO, consider resolving the issue 114 | "FIX002", 115 | 116 | # Compatibility 117 | 118 | # UP006: (3.9) - Use `list` instead of `List` for type annotation 119 | "UP006", 120 | # UP007: (3.10) - Use `X | Y` for type annotations 121 | "UP007", 122 | ] 123 | unfixable = [ 124 | # ERA001: Found commented-out code 125 | "ERA001", 126 | # F401: Unused import 127 | "F401", 128 | ] 129 | 130 | [tool.ruff.lint.flake8-tidy-imports] 131 | ban-relative-imports = "all" 132 | 133 | [tool.ruff.lint.pydocstyle] 134 | convention = "google" 135 | 136 | [tool.ruff.lint.per-file-ignores] 137 | "tests/**/*" = [ 138 | # PLR2004: Magic value used in comparison, consider replacing with a constant variable 139 | "PLR2004", 140 | # S101: Use of assert detected 141 | "S101", 142 | # S607: Starting a process with a partial executable path 143 | "S607", 144 | ] 145 | "__init__.py" = [ 146 | # F403: `from ... import *` used; unable to detect undefined names 147 | "F403", 148 | ] 149 | -------------------------------------------------------------------------------- /src/pyforce/__init__.py: -------------------------------------------------------------------------------- 1 | """A Python perforce API.""" 2 | 3 | from pyforce.commands import * 4 | from pyforce.exceptions import * 5 | from pyforce.models import * 6 | from pyforce.utils import * 7 | -------------------------------------------------------------------------------- /src/pyforce/commands.py: -------------------------------------------------------------------------------- 1 | """Pyforce commands.""" 2 | 3 | from __future__ import annotations 4 | 5 | import marshal 6 | import re 7 | import subprocess 8 | from typing import Iterator 9 | 10 | from pyforce.exceptions import ( 11 | AuthenticationError, 12 | ChangeUnknownError, 13 | ClientNotFoundError, 14 | CommandExecutionError, 15 | ConnectionExpiredError, 16 | UserNotFoundError, 17 | ) 18 | from pyforce.models import ( 19 | ActionInfo, 20 | ActionMessage, 21 | Change, 22 | ChangeInfo, 23 | ChangeStatus, 24 | Client, 25 | FStat, 26 | Revision, 27 | Sync, 28 | User, 29 | ) 30 | from pyforce.utils import ( 31 | Connection, 32 | MarshalCode, 33 | MessageSeverity, 34 | PerforceDict, 35 | extract_indexed_values, 36 | log, 37 | ) 38 | 39 | # TODO: submit_changelist 40 | # TODO: edit_changelist 41 | # TODO: move: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_move.html 42 | # TODO: opened 43 | 44 | __all__ = [ 45 | "get_user", 46 | "get_client", 47 | "get_change", 48 | "get_revisions", 49 | "create_changelist", 50 | "add", 51 | "edit", 52 | "delete", 53 | "sync", 54 | "login", 55 | "fstat", 56 | "p4", 57 | ] 58 | 59 | 60 | def get_user(connection: Connection, user: str) -> User: 61 | """Get user specification. 62 | 63 | Command: 64 | `p4 user`_. 65 | 66 | Args: 67 | connection: Perforce connection. 68 | user: Perforce username. 69 | """ 70 | data = p4(connection, ["user", "-o", user])[0] 71 | if "Update" not in data: 72 | msg = f"User {user!r} does not exists" 73 | raise UserNotFoundError(msg) 74 | return User(**data) # type: ignore[arg-type] 75 | 76 | 77 | def get_client(connection: Connection, client: str) -> Client: 78 | """Get client workspace specification. 79 | 80 | Command: 81 | `p4 client`_. 82 | 83 | Args: 84 | connection: Perforce connection. 85 | client: Perforce client name. 86 | """ 87 | data = p4(connection, ["client", "-o", client])[0] 88 | if "Update" not in data: 89 | msg = f"Client {client!r} does not exists" 90 | raise ClientNotFoundError(msg) 91 | return Client(**data) # type: ignore[arg-type] 92 | 93 | 94 | def get_change(connection: Connection, change: int) -> Change: 95 | """Get changelist specification. 96 | 97 | Command: 98 | `p4 change`_. 99 | 100 | Args: 101 | connection: Perforce connection. 102 | change: The changelist number. 103 | 104 | Raises: 105 | ChangeUnknownError: ``change`` not found. 106 | """ 107 | try: 108 | data = p4(connection, ["change", "-o", str(change)])[0] 109 | except CommandExecutionError as error: 110 | if error.data["data"].strip() == f"Change {change} unknown.": 111 | raise ChangeUnknownError(change) from error 112 | raise 113 | return Change(**data) # type: ignore[arg-type] 114 | 115 | 116 | def create_changelist(connection: Connection, description: str) -> ChangeInfo: 117 | """Create and return a new changelist. 118 | 119 | Command: 120 | `p4 change`_. 121 | 122 | Args: 123 | connection: Perforce connection. 124 | description: Changelist description. 125 | """ 126 | data = p4(connection, ["change", "-o"])[0] 127 | data["Description"] = description 128 | _ = extract_indexed_values(data, "Files") 129 | p4(connection, ["change", "-i"], stdin=data) 130 | 131 | data = p4(connection, ["changes", "--me", "-m", "1", "-l"])[0] 132 | return ChangeInfo(**data) # type: ignore[arg-type] 133 | 134 | 135 | def changes( 136 | connection: Connection, 137 | user: str | None = None, 138 | *, 139 | status: ChangeStatus | None = None, 140 | long_output: bool = False, 141 | ) -> Iterator[ChangeInfo]: 142 | """Iter submitted and pending changelists. 143 | 144 | Command: 145 | `p4 changes`_. 146 | 147 | Args: 148 | connection: Perforce connection. 149 | user: List only changes made from that user. 150 | status: List only changes with the specified status. 151 | long_output: List long output, with full text of each changelist description. 152 | """ 153 | command = ["changes"] 154 | if user: 155 | command += ["-u", user] 156 | if status: 157 | command += ["-s", str(status)] 158 | if long_output: 159 | command += ["-l"] 160 | 161 | for data in p4(connection, command): 162 | yield ChangeInfo(**data) # type: ignore[arg-type] 163 | 164 | 165 | def add( 166 | connection: Connection, 167 | filespecs: list[str], 168 | *, 169 | changelist: int | None = None, 170 | preview: bool = False, 171 | ) -> tuple[list[ActionMessage], list[ActionInfo]]: 172 | """Open ``filespecs`` in client workspace for **addition** to the depot. 173 | 174 | Command: 175 | `p4 add`_. 176 | 177 | Args: 178 | connection: Perforce connection. 179 | filespecs: A list of `File specifications`_. 180 | changelist: Open the files within the specified changelist. If not set, 181 | the files are linked to the default changelist. 182 | preview: Preview which files would be opened for add, without actually 183 | changing any files or metadata. 184 | 185 | Returns: 186 | `ActionInfo` and `ActionMessage` objects. `ActionInfo` are only included if 187 | something unexpected happened during the operation. 188 | """ 189 | command = ["add"] 190 | if changelist: 191 | command += ["-c", str(changelist)] 192 | if preview: 193 | command += ["-n"] 194 | command += filespecs 195 | 196 | messages: list[ActionMessage] = [] 197 | infos: list[ActionInfo] = [] 198 | for data in p4(connection, command): 199 | if data["code"] == MarshalCode.INFO: 200 | messages.append(ActionMessage.from_info_data(data)) 201 | else: 202 | infos.append(ActionInfo(**data)) # type: ignore[arg-type] 203 | return messages, infos 204 | 205 | 206 | def edit( 207 | connection: Connection, 208 | filespecs: list[str], 209 | *, 210 | changelist: int | None = None, 211 | preview: bool = False, 212 | ) -> tuple[list[ActionMessage], list[ActionInfo]]: 213 | """Open ``filespecs`` in client workspace for **edit**. 214 | 215 | Command: 216 | `p4 edit`_. 217 | 218 | Args: 219 | connection: Perforce connection. 220 | filespecs: A list of `File specifications`_. 221 | changelist: Open the files within the specified changelist. If not set, 222 | the files are linked to the default changelist. 223 | preview: Preview the result of the operation, without actually changing 224 | any files or metadata. 225 | 226 | Returns: 227 | `ActionInfo` and `ActionMessage` objects. `ActionInfo` are only included if 228 | something unexpected happened during the operation. 229 | """ 230 | command = ["edit"] 231 | if changelist: 232 | command += ["-c", str(changelist)] 233 | if preview: 234 | command += ["-n"] 235 | command += filespecs 236 | 237 | messages: list[ActionMessage] = [] 238 | infos: list[ActionInfo] = [] 239 | for data in p4(connection, command): 240 | if data["code"] == MarshalCode.INFO: 241 | messages.append(ActionMessage.from_info_data(data)) 242 | else: 243 | infos.append(ActionInfo(**data)) # type: ignore[arg-type] 244 | return messages, infos 245 | 246 | 247 | def delete( 248 | connection: Connection, 249 | filespecs: list[str], 250 | *, 251 | changelist: int | None = None, 252 | preview: bool = False, 253 | ) -> tuple[list[ActionMessage], list[ActionInfo]]: 254 | """Open ``filespecs`` in client workspace for **deletion** from the depo. 255 | 256 | Command: 257 | `p4 delete`_. 258 | 259 | Args: 260 | connection: Perforce connection. 261 | filespecs: A list of `File specifications`_. 262 | changelist: Open the files within the specified changelist. If not set, 263 | the files are linked to the default changelist. 264 | preview: Preview the result of the operation, without actually changing 265 | any files or metadata. 266 | 267 | Returns: 268 | `ActionInfo` and `ActionMessage` objects. `ActionInfo` are only included if 269 | something unexpected happened during the operation. 270 | """ 271 | # TODO: investigate '-v' and '-k' options 272 | command = ["delete"] 273 | if changelist: 274 | command += ["-c", str(changelist)] 275 | if preview: 276 | command += ["-n"] 277 | command += filespecs 278 | 279 | messages: list[ActionMessage] = [] 280 | infos: list[ActionInfo] = [] 281 | for data in p4(connection, command): 282 | if data["code"] == MarshalCode.INFO: 283 | messages.append(ActionMessage.from_info_data(data)) 284 | else: 285 | infos.append(ActionInfo(**data)) # type: ignore[arg-type] 286 | return messages, infos 287 | 288 | 289 | def fstat( 290 | connection: Connection, 291 | filespecs: list[str], 292 | *, 293 | include_deleted: bool = False, 294 | ) -> Iterator[FStat]: 295 | """List files information. 296 | 297 | Local files (not in depot and not opened for ``add``) are not included. 298 | 299 | Command: 300 | `p4 fstat`_. 301 | 302 | Args: 303 | connection: Perforce connection. 304 | filespecs: A list of `File specifications`_. 305 | include_deleted: Include files with a head action of ``delete`` or 306 | ``move/delete``. 307 | """ 308 | # NOTE: not using: '-Ol': include 'fileSize' and 'digest' fields. 309 | command = ["fstat"] 310 | if not include_deleted: 311 | command += ["-F", "^headAction=delete ^headAction=move/delete"] 312 | command += filespecs 313 | 314 | local_paths = set() # TODO: not doing anything with local paths at the moment 315 | for data in p4(connection, command, max_severity=MessageSeverity.WARNING): 316 | if data["code"] == "error": 317 | path, _, message = data["data"].rpartition(" - ") 318 | path, message = path.strip(), message.strip() 319 | if message == "no such file(s).": 320 | local_paths.add(path) 321 | else: 322 | raise CommandExecutionError(data["data"], command=command, data=data) 323 | else: 324 | yield FStat(**data) # type: ignore[arg-type] 325 | 326 | 327 | def get_revisions( 328 | connection: Connection, 329 | filespecs: list[str], 330 | *, 331 | long_output: bool = False, 332 | ) -> Iterator[list[Revision]]: 333 | """List **all** revisions of files matching ``filespecs``. 334 | 335 | Command: 336 | `p4 filelog`_. 337 | 338 | Args: 339 | connection: Perforce Connection. 340 | filespecs: A list of `File specifications`_. 341 | long_output: List long output, with full text of each changelist description. 342 | 343 | Warning: 344 | The lists are not intentionally sorted despite being *naturally* sorted by 345 | descending revision (highest to lowset) due to how `p4 filelog`_ data are 346 | processed. This behavior could change in the future, the order is not 347 | guaranteed. 348 | """ 349 | # NOTE: Most fields ends with the rev number, like 'foo1', other indicate a 350 | # relationship, like 'bar0,1' 351 | regex = re.compile(r"([a-zA-Z]+)([0-9]+)(?:,([0-9]+))?") 352 | 353 | command = ["filelog"] 354 | if long_output: 355 | command += ["-l"] 356 | command += filespecs 357 | 358 | for data in p4(connection, command): 359 | revisions: dict[int, dict[str, str]] = {} 360 | shared: dict[str, str] = {} 361 | 362 | for key, value in data.items(): 363 | match = regex.match(key) 364 | 365 | if match: 366 | prefix: str = match.group(1) 367 | index = int(match.group(2)) 368 | suffix = "" if match.group(3) is None else int(match.group(3)) 369 | revisions.setdefault(index, {})[f"{prefix}{suffix}"] = value 370 | else: 371 | shared[key] = value 372 | 373 | yield [Revision(**rev, **shared) for rev in revisions.values()] # type: ignore[arg-type] 374 | 375 | 376 | def sync(connection: Connection, filespecs: list[str]) -> list[Sync]: 377 | """Update ``filespecs`` to the client workspace. 378 | 379 | Args: 380 | connection: Perforce Connection. 381 | filespecs: A list of `File specifications`_. 382 | """ 383 | # TODO: parallel as an option 384 | command = ["sync", *filespecs] 385 | output = p4(connection, command, max_severity=MessageSeverity.WARNING) 386 | 387 | result: list = [] 388 | for data in output: 389 | if data["code"] == MarshalCode.ERROR: 390 | _, _, message = data["data"].rpartition(" - ") 391 | message = message.strip() 392 | if message == "file(s) up-to-date.": 393 | log.debug(data["data"].strip()) 394 | continue 395 | raise CommandExecutionError(message, command=command, data=data) 396 | 397 | if data["code"] == MarshalCode.INFO: 398 | log.info(data["data"].strip()) 399 | continue 400 | 401 | # NOTE: The first item contain info about total file count and size. 402 | if not result and "totalFileCount" in data: 403 | total_files = int(data.pop("totalFileCount", 0)) 404 | total_bytes = int(data.pop("totalFileSize", 0)) 405 | log.info("Synced %s files (%s bytes)", total_files, total_bytes) 406 | 407 | result.append(Sync(**data)) # type: ignore[arg-type] 408 | 409 | return result 410 | 411 | 412 | def login(connection: Connection, password: str) -> None: 413 | """Login to Perforce Server. 414 | 415 | Raises: 416 | AuthenticationError: Failed to login. 417 | """ 418 | command = ["p4", "-p", connection.port] 419 | if connection.user: 420 | command += ["-u", connection.user] 421 | command += ["login"] 422 | 423 | process = subprocess.Popen( 424 | command, 425 | stdin=subprocess.PIPE, 426 | stdout=subprocess.DEVNULL, 427 | stderr=subprocess.PIPE, 428 | ) 429 | _, stderr = process.communicate(password.encode()) 430 | 431 | if stderr: 432 | raise AuthenticationError(stderr.decode().strip()) 433 | 434 | 435 | def p4( 436 | connection: Connection, 437 | command: list[str], 438 | stdin: PerforceDict | None = None, 439 | max_severity: MessageSeverity = MessageSeverity.EMPTY, 440 | ) -> list[PerforceDict]: 441 | """Run a ``p4`` command and return its output. 442 | 443 | This function uses `marshal` (using ``p4 -G``) to load stdout and dump stdin. 444 | 445 | Args: 446 | connection: The connection to execute the command with. 447 | command: A ``p4`` command to execute, with arguments. 448 | stdin: Write a dict to the standard input file using `marshal.dump`. 449 | max_severity: Raises an exception if the output error severity is above 450 | that threshold. 451 | 452 | Returns: 453 | The command output. 454 | 455 | Raises: 456 | CommandExecutionError: An error occured during command execution. 457 | ConnectionExpiredError: Connection to server expired, password is required. 458 | You can use the `login` function. 459 | """ 460 | args = ["p4", "-G", "-p", connection.port] 461 | if connection.user: 462 | args += ["-u", connection.user] 463 | if connection.client: 464 | args += ["-c", connection.client] 465 | args += command 466 | 467 | log.debug("Running: '%s'", " ".join(args)) 468 | 469 | result: list[PerforceDict] = [] 470 | process = subprocess.Popen( 471 | args, 472 | stdout=subprocess.PIPE, 473 | stdin=subprocess.PIPE if stdin else None, 474 | stderr=subprocess.PIPE, 475 | ) 476 | with process: 477 | if stdin: 478 | assert process.stdin is not None # noqa: S101 479 | marshal.dump(stdin, process.stdin, 0) # NOTE: perforce require version 0 480 | else: 481 | assert process.stdout is not None # noqa: S101 482 | while True: 483 | try: 484 | out: dict[bytes, bytes | int] = marshal.load(process.stdout) # noqa: S302 485 | except EOFError: 486 | break 487 | 488 | # NOTE: Some rare values, like user FullName, can be encoded 489 | # differently, and decoding them with 'latin-1' give us a result 490 | # that seem to match what P4V does. 491 | data = { 492 | key.decode(): val.decode("latin-1") 493 | if isinstance(val, bytes) 494 | else str(val) 495 | for key, val in out.items() 496 | } 497 | 498 | if ( 499 | data.get("code") == MarshalCode.ERROR 500 | and int(data["severity"]) > max_severity 501 | ): 502 | message = str(data["data"].strip()) 503 | 504 | if message == "Perforce password (P4PASSWD) invalid or unset.": 505 | message = "Perforce connection expired, password is required" 506 | raise ConnectionExpiredError(message) 507 | 508 | raise CommandExecutionError(message, command=args, data=data) 509 | 510 | result.append(data) 511 | 512 | _, stderr = process.communicate() 513 | if stderr: 514 | message = stderr.decode() 515 | raise CommandExecutionError(message, command=args) 516 | 517 | return result 518 | -------------------------------------------------------------------------------- /src/pyforce/exceptions.py: -------------------------------------------------------------------------------- 1 | """Pyforce Exceptions.""" 2 | 3 | from __future__ import annotations 4 | 5 | __all__ = [ 6 | "PyforceError", 7 | "ConnectionExpiredError", 8 | "AuthenticationError", 9 | "ChangeUnknownError", 10 | "CommandExecutionError", 11 | "UserNotFoundError", 12 | "ClientNotFoundError", 13 | ] 14 | 15 | 16 | class PyforceError(Exception): 17 | """Base exception for the `pyforce` package.""" 18 | 19 | 20 | class UserNotFoundError(PyforceError): 21 | """Raised when a user is request but doesn't exists.""" 22 | 23 | 24 | class ChangeUnknownError(PyforceError): 25 | """Raised when a changelist is request but doesn't exists.""" 26 | 27 | 28 | class ClientNotFoundError(PyforceError): 29 | """Raised when a client workspace is request but doesn't exists.""" 30 | 31 | 32 | class ConnectionExpiredError(PyforceError): 33 | """Raised when the connection to the Helix Core Server has expired. 34 | 35 | You need to log back in. 36 | """ 37 | 38 | 39 | class AuthenticationError(PyforceError): 40 | """Raised when login to the Helix Core Server failed.""" 41 | 42 | 43 | class CommandExecutionError(PyforceError): 44 | """Raised when an error occured during the execution of a `p4` command. 45 | 46 | Args: 47 | message: Error message. 48 | command: The executed command. 49 | data: Optional marshalled output returned by the command. 50 | """ 51 | 52 | def __init__( 53 | self, 54 | message: str, 55 | command: list[str], 56 | data: dict[str, str] | None = None, 57 | ) -> None: 58 | self.command = command 59 | self.data = data or {} 60 | super().__init__(message) 61 | -------------------------------------------------------------------------------- /src/pyforce/models.py: -------------------------------------------------------------------------------- 1 | """Pyforce models.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pathlib # noqa: TCH003 6 | import shlex 7 | from dataclasses import dataclass 8 | from typing import Any, Iterable, List, Literal, Mapping, NamedTuple, Union, cast 9 | 10 | from pydantic import ( 11 | BaseModel, 12 | ConfigDict, 13 | Field, 14 | field_validator, 15 | model_validator, 16 | ) 17 | 18 | from pyforce.utils import ( 19 | MessageLevel, 20 | PerforceDateTime, 21 | PerforceDict, 22 | PerforceTimestamp, 23 | StrEnum, 24 | extract_indexed_values, 25 | ) 26 | 27 | __all__ = [ 28 | "UserType", 29 | "AuthMethod", 30 | "User", 31 | "View", 32 | "ClientType", 33 | "ClientOptions", 34 | "SubmitOptions", 35 | "Client", 36 | "ChangeStatus", 37 | "ChangeType", 38 | "Change", 39 | "ChangeInfo", 40 | "Action", 41 | "ActionMessage", 42 | "ActionInfo", 43 | "Revision", 44 | "Sync", 45 | "OtherOpen", 46 | "HeadInfo", 47 | "FStat", 48 | ] 49 | 50 | 51 | class PyforceModel(BaseModel): 52 | model_config = ConfigDict(extra="allow") 53 | 54 | def __repr_args__(self) -> Iterable[tuple[str | None, Any]]: 55 | """Filter out extras.""" 56 | extra = self.__pydantic_extra__ 57 | if not extra: 58 | return super().__repr_args__() 59 | return ( 60 | (key, val) for (key, val) in super().__repr_args__() if key not in extra 61 | ) 62 | 63 | 64 | class UserType(StrEnum): 65 | """Types of user enum.""" 66 | 67 | STANDARD = "standard" 68 | OPERATOR = "operator" 69 | SERVICE = "service" 70 | 71 | 72 | class AuthMethod(StrEnum): 73 | """User authentication enum.""" 74 | 75 | PERFORCE = "perforce" 76 | LDAP = "ldap" 77 | 78 | 79 | class User(PyforceModel): 80 | """A Perforce user specification.""" 81 | 82 | access: PerforceDateTime = Field(alias="Access", repr=False) 83 | """The date and time this user last ran a Helix Server command.""" 84 | 85 | auth_method: AuthMethod = Field(alias="AuthMethod", repr=False) 86 | email: str = Field(alias="Email") 87 | full_name: str = Field(alias="FullName") 88 | type: UserType = Field(alias="Type") 89 | 90 | update: PerforceDateTime = Field(alias="Update", repr=False) 91 | """The date and time this user was last updated.""" 92 | 93 | name: str = Field(alias="User") 94 | 95 | 96 | class SubmitOptions(StrEnum): 97 | """Options to govern the default behavior of ``p4 submit``.""" 98 | 99 | SUBMIT_UNCHANGED = "submitunchanged" 100 | SUBMIT_UNCHANGED_AND_REOPEN = "submitunchanged+reopen" 101 | REVERT_UNCHANGED = "revertunchanged" 102 | REVERT_UNCHANGED_AND_REOPEN = "revertunchanged+reopen" 103 | LEAVE_UNCHANGED = "leaveunchanged" 104 | LEAVE_UNCHANGED_AND_REOPEN = "leaveunchanged+reopen" 105 | 106 | 107 | class ClientType(StrEnum): 108 | """Types of client workspace.""" 109 | 110 | STANDARD = "writeable" 111 | OPERATOR = "readonly" 112 | SERVICE = "partitioned" 113 | 114 | 115 | class View(NamedTuple): 116 | """A perforce `View specification`_.""" 117 | 118 | left: str 119 | right: str 120 | 121 | @staticmethod 122 | def from_string(string: str) -> View: 123 | """New instance from a view string. 124 | 125 | Example: 126 | >>> View.from_string("//depot/foo/... //ws/bar/...") 127 | View(left='//depot/foo/...', right='//ws/bar/...') 128 | """ 129 | return View(*shlex.split(string)) 130 | 131 | 132 | @dataclass 133 | class ClientOptions: 134 | """A set of switches that control particular `Client options`_. 135 | 136 | .. _Client options: 137 | https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_client.html#Options2 138 | """ 139 | 140 | allwrite: bool 141 | clobber: bool 142 | compress: bool 143 | locked: bool 144 | modtime: bool 145 | rmdir: bool 146 | 147 | @classmethod 148 | def from_string(cls, string: str) -> ClientOptions: 149 | """Instanciate class from an option line returned by p4.""" 150 | data = set(string.split()) 151 | return cls( 152 | allwrite="allwrite" in data, 153 | clobber="clobber" in data, 154 | compress="compress" in data, 155 | locked="locked" in data, 156 | modtime="modtime" in data, 157 | rmdir="rmdir" in data, 158 | ) 159 | 160 | def __str__(self) -> str: 161 | options = [ 162 | "allwrite" if self.allwrite else "noallwrite", 163 | "clobber" if self.clobber else "noclobber", 164 | "compress" if self.compress else "nocompress", 165 | "locked" if self.locked else "nolocked", 166 | "modtime" if self.modtime else "nomodtime", 167 | "rmdir" if self.rmdir else "normdir", 168 | ] 169 | return " ".join(options) 170 | 171 | 172 | class Client(PyforceModel): 173 | """A Perforce client workspace specification.""" 174 | 175 | access: PerforceDateTime = Field(alias="Access", repr=False) 176 | """The date and time that the workspace was last used in any way.""" 177 | 178 | name: str = Field(alias="Client") 179 | description: str = Field(alias="Description", repr=False) 180 | 181 | host: str = Field(alias="Host") 182 | """The name of the workstation on which this workspace resides.""" 183 | 184 | options: ClientOptions = Field(alias="Options", repr=False) 185 | 186 | owner: str = Field(alias="Owner") 187 | """The name of the user who owns the workspace.""" 188 | 189 | root: pathlib.Path = Field(alias="Root") 190 | """Workspace root directory on the local host 191 | 192 | All the file in `views` are relative to this directory. 193 | """ 194 | 195 | stream: Union[str, None] = Field(alias="Stream", default=None) 196 | submit_options: SubmitOptions = Field(alias="SubmitOptions", repr=False) 197 | type: ClientType = Field(alias="Type") 198 | 199 | update: PerforceDateTime = Field(alias="Update", repr=False) 200 | """The date the workspace specification was last modified.""" 201 | 202 | views: List[View] 203 | """Specifies the mappings between files in the depot and files in the workspace.""" 204 | 205 | @field_validator("options", mode="before") 206 | @classmethod 207 | def _validate_options(cls, v: str) -> ClientOptions: 208 | return ClientOptions.from_string(v) 209 | 210 | @model_validator(mode="before") 211 | @classmethod 212 | def _prepare_views(cls, data: dict[str, object]) -> dict[str, object]: 213 | data["views"] = [ 214 | View.from_string(cast(str, v)) 215 | for v in extract_indexed_values(data, prefix="View") 216 | ] 217 | return data 218 | 219 | 220 | class ChangeStatus(StrEnum): 221 | """Types of changelist status.""" 222 | 223 | PENDING = "pending" 224 | SHELVED = "shelved" 225 | SUBMITTED = "submitted" 226 | # TODO: NEW = "new" ? 227 | 228 | 229 | class ChangeType(StrEnum): 230 | """Types of changelist.""" 231 | 232 | RESTRICTED = "restricted" 233 | PUBLIC = "public" 234 | 235 | 236 | class Change(PyforceModel): 237 | """A Perforce changelist specification. 238 | 239 | Command: 240 | `p4 change`_ 241 | """ 242 | 243 | change: int = Field(alias="Change") 244 | client: str = Field(alias="Client") 245 | 246 | date: PerforceDateTime = Field(alias="Date") 247 | """Date the changelist was last modified.""" 248 | 249 | description: str = Field(alias="Description", repr=False) 250 | status: ChangeStatus = Field(alias="Status") 251 | type: ChangeType = Field(alias="Type") 252 | 253 | user: str = Field(alias="User") 254 | """Name of the change owner.""" 255 | 256 | files: List[str] = Field(repr=False) 257 | """The list of files being submitted in this changelist.""" 258 | 259 | shelve_access: Union[PerforceDateTime, None] = Field( 260 | alias="shelveAccess", 261 | default=None, 262 | ) 263 | shelve_update: Union[PerforceDateTime, None] = Field( 264 | alias="shelveUpdate", 265 | default=None, 266 | ) 267 | 268 | @model_validator(mode="before") 269 | @classmethod 270 | def _prepare_files(cls, data: dict[str, object]) -> dict[str, object]: 271 | data["files"] = extract_indexed_values(data, prefix="Files") 272 | return data 273 | 274 | 275 | class ChangeInfo(PyforceModel): 276 | """A Perforce changelist. 277 | 278 | Compared to a `Change`, this model does not contain the files in the changelist. 279 | 280 | Command: 281 | `p4 changes`_ 282 | """ 283 | 284 | change: int = Field(alias="change") 285 | client: str = Field(alias="client") 286 | 287 | date: PerforceTimestamp = Field(alias="time") 288 | """Date the changelist was last modified.""" 289 | 290 | description: str = Field(alias="desc", repr=False) 291 | status: ChangeStatus = Field(alias="status") 292 | type: ChangeType = Field(alias="changeType") 293 | 294 | user: str = Field(alias="user") 295 | """Name of the change owner.""" 296 | 297 | 298 | class Action(StrEnum): 299 | """A file action.""" 300 | 301 | ADD = "add" 302 | EDIT = "edit" 303 | DELETE = "delete" 304 | BRANCH = "branch" 305 | MOVE_ADD = "move/add" 306 | MOVE_DELETE = "move/delete" 307 | INTEGRATE = "integrate" 308 | IMPORT = "import" 309 | PURGE = "purge" 310 | ARCHIVE = "archive" 311 | 312 | 313 | @dataclass(frozen=True) 314 | class ActionMessage: 315 | """Information on a file during an action operation. 316 | 317 | Actions can be, for example, ``add``, ``edit`` or ``remove``. 318 | 319 | Notable messages: 320 | - "can't add (already opened for edit)" 321 | - "can't add existing file" 322 | - "empty, assuming text." 323 | - "also opened by user@client" 324 | """ 325 | 326 | path: str 327 | message: str 328 | level: MessageLevel 329 | 330 | @classmethod 331 | def from_info_data(cls, data: PerforceDict) -> ActionMessage: 332 | """Create instance from an 'info' dict of an action command.""" 333 | path, _, message = data["data"].rpartition(" - ") 334 | level = MessageLevel(int(data["level"])) 335 | return cls( 336 | path=path.strip(), 337 | message=message.strip(), 338 | level=level, 339 | ) 340 | 341 | 342 | class ActionInfo(PyforceModel): 343 | """The result of an action operation. 344 | 345 | Actions can be, for example, ``add``, ``edit`` or ``remove``. 346 | """ 347 | 348 | action: str 349 | client_file: str = Field(alias="clientFile") 350 | depot_file: str = Field(alias="depotFile") 351 | file_type: str = Field(alias="type") # TODO: create object type ? 'binary+F' 352 | work_rev: int = Field(alias="workRev") 353 | """Open revision.""" 354 | 355 | 356 | class Revision(PyforceModel): 357 | """A file revision information.""" 358 | 359 | action: Action 360 | """The operation the file was open for.""" 361 | 362 | change: int 363 | """The number of the submitting changelist.""" 364 | 365 | client: str 366 | depot_file: str = Field(alias="depotFile") 367 | description: str = Field(alias="desc", repr=False) 368 | 369 | digest: Union[str, None] = Field(default=None, repr=False) 370 | """MD5 digest of the file. ``None`` if ``action`` is `Action.DELETE`.""" 371 | 372 | # TODO: None when action is 'deleted' 373 | file_size: Union[int, None] = Field(default=None) 374 | """File length in bytes. ``None`` if ``action`` is `Action.DELETE`.""" 375 | 376 | revision: int = Field(alias="rev") 377 | time: PerforceTimestamp 378 | 379 | # TODO: enum ? https://www.perforce.com/manuals/cmdref/Content/CmdRef/file.types.html 380 | file_type: str = Field(alias="type") 381 | 382 | user: str 383 | """The name of the user who submitted the revision.""" 384 | 385 | 386 | class Sync(PyforceModel): 387 | """The result of a file sync operation.""" 388 | 389 | action: str 390 | client_file: str = Field(alias="clientFile") # TODO: Could be Path 391 | depot_file: str = Field(alias="depotFile") 392 | revision: int = Field(alias="rev") # TODO: can be None ? 393 | 394 | # TODO: can be None if action is 'delete' or 'move/delete' 395 | file_size: int = Field(alias="fileSize") 396 | 397 | 398 | @dataclass(frozen=True) 399 | class OtherOpen: 400 | """Other Open information.""" 401 | 402 | action: Action 403 | change: Union[int, Literal["default"]] 404 | user: str 405 | client: str 406 | 407 | 408 | class HeadInfo(PyforceModel): 409 | """Head revision information.""" 410 | 411 | action: Action = Field(alias="headAction") 412 | change: int = Field(alias="headChange") 413 | revision: int = Field(alias="headRev") 414 | """Revision number. 415 | 416 | If you used a `Revision specifier`_ in your query, this field is set to the 417 | specified value. Otherwise, it's the head revision. 418 | 419 | .. _Revision specifier: 420 | https://www.perforce.com/manuals/cmdref/Content/CmdRef/filespecs.html#Using_revision_specifiers 421 | """ 422 | 423 | file_type: str = Field(alias="headType") 424 | time: PerforceTimestamp = Field(alias="headTime") 425 | """Revision **changelist** time.""" 426 | 427 | mod_time: PerforceTimestamp = Field(alias="headModTime") 428 | """Revision modification time. 429 | 430 | The time that the file was last modified on the client before submit. 431 | """ 432 | 433 | 434 | class FStat(PyforceModel): 435 | """A file information.""" 436 | 437 | client_file: str = Field(alias="clientFile") 438 | depot_file: str = Field(alias="depotFile") 439 | head: Union[HeadInfo, None] = Field(alias="head", default=None) 440 | 441 | have_rev: Union[int, None] = Field(alias="haveRev", default=None) 442 | """Revision last synced to workspace, if on workspace.""" 443 | 444 | is_mapped: bool = Field(alias="isMapped", default=False) 445 | """Is the file is mapped to client workspace.""" 446 | 447 | others_open: Union[List[OtherOpen], None] = Field(default=None) 448 | 449 | @field_validator("is_mapped", mode="before") 450 | @classmethod 451 | def _validate_is_mapped(cls, v: str | None) -> bool: 452 | return v == "" 453 | 454 | @model_validator(mode="before") 455 | @classmethod 456 | def _prepare_head(cls, data: dict[str, object]) -> dict[str, object]: 457 | if "headRev" not in data: 458 | return data 459 | 460 | head_info = { 461 | "headAction": data.pop("headAction"), 462 | "headChange": data.pop("headChange"), 463 | "headRev": data.pop("headRev"), 464 | "headType": data.pop("headType"), 465 | "headTime": data.pop("headTime"), 466 | "headModTime": data.pop("headModTime"), 467 | } 468 | 469 | charset = data.pop("headCharset", None) 470 | if charset: 471 | head_info["headCharset"] = charset 472 | 473 | data["head"] = head_info 474 | return data 475 | 476 | @model_validator(mode="before") 477 | @classmethod 478 | def _prepare_others_open(cls, data: dict[str, object]) -> Mapping[str, object]: 479 | total = cast("str | None", data.pop("otherOpen", None)) 480 | if total is None: 481 | return data 482 | 483 | result: list[OtherOpen] = [] 484 | for index in range(int(total)): 485 | user, _, client = cast(str, data.pop(f"otherOpen{index}")).partition("@") 486 | other = OtherOpen( 487 | action=Action(cast(str, data.pop(f"otherAction{index}"))), 488 | change=int(cast(str, data.pop(f"otherChange{index}"))), 489 | user=user, 490 | client=client, 491 | ) 492 | result.append(other) 493 | 494 | data["others_open"] = result 495 | return data 496 | -------------------------------------------------------------------------------- /src/pyforce/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tahv/pyforce/e19493bba7b2f03d79589a6af089adb2731c9ff5/src/pyforce/py.typed -------------------------------------------------------------------------------- /src/pyforce/utils.py: -------------------------------------------------------------------------------- 1 | """Pyforce utils.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import itertools 7 | import logging 8 | import sys 9 | from enum import Enum, IntEnum 10 | from typing import Dict, Final, NamedTuple 11 | 12 | from pydantic import BeforeValidator, PlainSerializer 13 | from typing_extensions import Annotated, TypeVar 14 | 15 | if sys.version_info < (3, 11): 16 | 17 | class StrEnum(str, Enum): 18 | """Enum where members are also (and must be) stings.""" 19 | else: 20 | from enum import StrEnum 21 | 22 | 23 | __all__ = [ 24 | "PerforceDict", 25 | "Connection", 26 | "MessageSeverity", 27 | "MarshalCode", 28 | "MessageLevel", 29 | ] 30 | 31 | log: Final = logging.getLogger("pyforce") 32 | 33 | PerforceDict = Dict[str, str] 34 | 35 | R = TypeVar("R") 36 | 37 | 38 | class Connection(NamedTuple): 39 | """Perforce connection informations.""" 40 | 41 | port: str 42 | """Perforce host and port (``P4PORT``).""" 43 | 44 | user: str | None = None 45 | """Helix server username (``P4USER``).""" 46 | 47 | client: str | None = None 48 | """Client workspace name (``P4CLIENT``).""" 49 | 50 | 51 | class MarshalCode(StrEnum): 52 | """Values of the ``code`` field from a marshaled P4 response. 53 | 54 | The output dictionary from ``p4 -G`` must have a ``code`` field. 55 | """ 56 | 57 | STAT = "stat" 58 | """Means 'status' and is the default status.""" 59 | 60 | ERROR = "error" 61 | """An error has occured. 62 | 63 | The full error message is contained in the 'data' field. 64 | """ 65 | 66 | INFO = "info" 67 | """There was some feedback from the command. 68 | 69 | The message is contained in the 'data' field. 70 | """ 71 | 72 | 73 | class MessageSeverity(IntEnum): 74 | """Perforce message severity levels.""" 75 | 76 | EMPTY = 0 77 | """No Error.""" 78 | 79 | INFO = 1 80 | """Informational message, something good happened.""" 81 | 82 | WARNING = 2 83 | """Warning message, something not good happened.""" 84 | 85 | FAILED = 3 86 | """Command failed, user did something wrong.""" 87 | 88 | FATAL = 4 89 | """System broken, severe error, cannot continue.""" 90 | 91 | 92 | class MessageLevel(IntEnum): 93 | """Perforce generic 'level' codes, as described in `P4.Message`_. 94 | 95 | .. _P4.Message: 96 | https://www.perforce.com/manuals/p4python/Content/P4Python/python.p4_message.html 97 | """ 98 | 99 | NONE = 0 100 | """Miscellaneous.""" 101 | 102 | USAGE = 0x01 103 | """Request is not consistent with dox.""" 104 | 105 | UNKNOWN = 0x02 106 | """Using unknown entity.""" 107 | 108 | CONTEXT = 0x03 109 | """Using entity in the wrong context.""" 110 | 111 | ILLEGAL = 0x04 112 | """You do not have permission to perform this action.""" 113 | 114 | NOTYET = 0x05 115 | """An issue needs to be fixed before you can perform this action.""" 116 | 117 | PROTECT = 0x06 118 | """Protections prevented operation.""" 119 | 120 | EMPTY = 0x11 121 | """Action returned empty results.""" 122 | 123 | FAULT = 0x21 124 | """Inexplicable program fault.""" 125 | 126 | CLIENT = 0x22 127 | """Client side program errors.""" 128 | 129 | ADMIN = 0x23 130 | """Server administrative action required.""" 131 | 132 | CONFIG = 0x24 133 | """Client configuration is inadequate.""" 134 | 135 | UPGRADE = 0x25 136 | """Client or server too old to interact.""" 137 | 138 | COMM = 0x26 139 | """Communications error.""" 140 | 141 | TOOBIG = 0x27 142 | """Too big to handle.""" 143 | 144 | 145 | PERFORCE_DATE_FORMAT = "%Y/%m/%d %H:%M:%S" 146 | 147 | 148 | def perforce_date_to_datetime(string: str) -> datetime.datetime: 149 | utc = datetime.timezone.utc 150 | return datetime.datetime.strptime(string, PERFORCE_DATE_FORMAT).replace(tzinfo=utc) 151 | 152 | 153 | def perforce_timestamp_to_datetime(time: str) -> datetime.datetime: 154 | return datetime.datetime.fromtimestamp(int(time), tz=datetime.timezone.utc) 155 | 156 | 157 | def perforce_datetime_to_timestamp(date: datetime.datetime) -> str: 158 | return str(round(date.timestamp())) 159 | 160 | 161 | def datetime_to_perforce_date(date: datetime.datetime) -> str: 162 | return date.strftime(PERFORCE_DATE_FORMAT) 163 | 164 | 165 | PerforceDateTime = Annotated[ 166 | datetime.datetime, 167 | BeforeValidator(perforce_date_to_datetime), 168 | PlainSerializer(datetime_to_perforce_date), 169 | ] 170 | 171 | 172 | PerforceTimestamp = Annotated[ 173 | datetime.datetime, 174 | BeforeValidator(perforce_timestamp_to_datetime), 175 | PlainSerializer(perforce_datetime_to_timestamp), 176 | ] 177 | 178 | 179 | def extract_indexed_values(data: dict[str, R], prefix: str) -> list[R]: 180 | """Pop indexed keys, in `data` to list of value. 181 | 182 | Args: 183 | data: dict to pop process. 184 | prefix: Indexed key prefix. For example, the keys in 185 | `{'Files0': "//depot/foo", 'Files1': "//depot/bar"}` have the prefix 186 | `Files`. 187 | """ 188 | result: list[R] = [] 189 | counter = itertools.count() 190 | 191 | while True: 192 | index = next(counter) 193 | value: R | None = data.pop(f"{prefix}{index}", None) 194 | if value is None: 195 | break 196 | result.append(value) 197 | 198 | return result 199 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration and fixtures.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import shutil 7 | import subprocess 8 | import tempfile 9 | import time 10 | import uuid 11 | from pathlib import Path 12 | from typing import Iterator, Protocol 13 | 14 | import pytest 15 | 16 | import pyforce 17 | 18 | 19 | def create_user( 20 | connection: pyforce.Connection, 21 | name: str, 22 | full_name: str | None = None, 23 | email: str | None = None, 24 | ) -> pyforce.User: 25 | """Create a new user on perforce server and return it.""" 26 | data = pyforce.p4(connection, ["user", "-o", name])[0] 27 | 28 | if full_name is not None: 29 | data["FullName"] = full_name 30 | if email is not None: 31 | data["Email"] = email 32 | 33 | pyforce.p4(connection, ["user", "-i", "-f"], stdin=data) 34 | return pyforce.get_user(connection, name) 35 | 36 | 37 | class UserFactory(Protocol): 38 | """Create and return a new user.""" 39 | 40 | def __call__(self, name: str) -> pyforce.User: # noqa: D102 41 | ... 42 | 43 | 44 | @pytest.fixture() 45 | def user_factory(server: str) -> Iterator[UserFactory]: 46 | """Factory fixture that create and return a new user.""" 47 | connection = pyforce.Connection(port=server) 48 | created: list[pyforce.User] = [] 49 | 50 | def factory(name: str) -> pyforce.User: 51 | user = create_user(connection, name) 52 | created.append(user) 53 | return user 54 | 55 | yield factory 56 | 57 | for user in created: 58 | pyforce.p4(connection, ["user", "-d", "-f", user.name]) 59 | 60 | 61 | def create_client( 62 | connection: pyforce.Connection, 63 | name: str, 64 | stream: str | None = None, 65 | root: Path | None = None, 66 | ) -> pyforce.Client: 67 | """Create a new `Client` on perforce server and return it.""" 68 | command = ["client", "-o"] 69 | if stream is not None: 70 | command += ["-S", stream] 71 | command += [name] 72 | 73 | data = pyforce.p4(connection, command)[0] 74 | 75 | if root is not None: 76 | data["Root"] = str(root.resolve()) 77 | 78 | pyforce.p4(connection, ["client", "-i"], stdin=data) 79 | return pyforce.get_client(connection, name) 80 | 81 | 82 | class ClientFactory(Protocol): 83 | """Create and return a new client.""" 84 | 85 | def __call__(self, name: str, stream: str | None = None) -> pyforce.Client: # noqa: D102 86 | ... 87 | 88 | 89 | @pytest.fixture() 90 | def client_factory(server: str) -> Iterator[ClientFactory]: 91 | """Factory fixture that create and return a new client.""" 92 | connection = pyforce.Connection(port=server) 93 | created: list[tuple[pyforce.Client, Path]] = [] 94 | 95 | def factory(name: str, stream: str | None = None) -> pyforce.Client: 96 | root = Path(tempfile.mkdtemp(prefix="pyforce-client-")) 97 | client = create_client(connection, name, stream=stream, root=root) 98 | created.append((client, root)) 99 | return client 100 | 101 | yield factory 102 | 103 | for client, root in created: 104 | pyforce.p4(connection, ["client", "-d", "-f", client.name]) 105 | if root.exists(): 106 | shutil.rmtree(root) 107 | 108 | 109 | @pytest.fixture() 110 | def client(server: str) -> Iterator[pyforce.Client]: 111 | """Create a client on a mainline stream for the duration of the test. 112 | 113 | This fixture create (and tear-down) a stream depot, a mainline stream and 114 | a client on that stream. 115 | """ 116 | connection = pyforce.Connection(port=server) 117 | 118 | depot = f"depot-{uuid.uuid4().hex}" 119 | data = pyforce.p4(connection, ["depot", "-o", "-t", "stream", depot])[0] 120 | pyforce.p4(connection, ["depot", "-i"], stdin=data) 121 | 122 | stream = f"//{depot}/stream-{uuid.uuid4().hex}" 123 | data = pyforce.p4(connection, ["stream", "-o", "-t", "mainline", stream])[0] 124 | pyforce.p4(connection, ["stream", "-i"], stdin=data) 125 | 126 | client = create_client( 127 | connection, 128 | f"client-{uuid.uuid4().hex}", 129 | stream=stream, 130 | root=Path(tempfile.mkdtemp(prefix="pyforce-client-")), 131 | ) 132 | 133 | yield client 134 | 135 | pyforce.p4(connection, ["client", "-d", "-f", client.name]) 136 | pyforce.p4(connection, ["stream", "-d", "--obliterate", "-y", stream]) 137 | pyforce.p4(connection, ["obliterate", "-y", f"//{depot}/..."]) 138 | pyforce.p4(connection, ["depot", "-d", depot]) 139 | shutil.rmtree(client.root) 140 | 141 | 142 | class FileFactory(Protocol): 143 | """Create and return a new file.""" 144 | 145 | def __call__(self, root: Path, prefix: str = "file", content: str = "") -> Path: # noqa: D102 146 | ... 147 | 148 | 149 | @pytest.fixture() 150 | def file_factory() -> FileFactory: 151 | """Factory fixture that create and return a new file.""" 152 | 153 | def factory(root: Path, prefix: str = "file", content: str = "") -> Path: 154 | path = root / f"{prefix}-{uuid.uuid4().hex}" 155 | with path.open("w") as fp: 156 | fp.write(content) 157 | return path 158 | 159 | return factory 160 | 161 | 162 | @pytest.fixture(autouse=True, scope="session") 163 | def server() -> Iterator[str]: 164 | """Create a temporary perforce server for the duration of the test session. 165 | 166 | Yield: 167 | The server port ('localhost:1666'). 168 | """ 169 | # Backup p4 variables set as env variables, and clear them. 170 | # This should override all variables that could cause issue with the tests 171 | # We override 'P4ENVIRON' and 'P4CONFIG' because both files, if set, could 172 | # overrides environment variables. 173 | # Predecence for variables: 174 | # https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_set.html 175 | backup_env: dict[str, str] = {} 176 | for key in ("P4CLIENT", "P4PORT", "P4USER", "P4CONFIG", "P4ENVIRO"): 177 | value = os.environ.pop(key, None) 178 | if value is not None: 179 | backup_env[key] = value 180 | 181 | # Run server 182 | root = Path(tempfile.mkdtemp(prefix="pyforce-server-")) 183 | port = "localhost:1666" # default port on localhost 184 | user = "remote" # same as p4d default 185 | timeout = 5 # in seconds 186 | 187 | process = subprocess.Popen(["p4d", "-r", str(root), "-p", port, "-u", user]) 188 | start_time = time.time() 189 | 190 | while True: 191 | if time.time() > start_time + timeout: 192 | msg = f"Server initialization timed out after {timeout} seconds" 193 | raise RuntimeError(msg) 194 | 195 | try: 196 | subprocess.check_call(["p4", "-p", port, "-u", user, "info"]) 197 | except subprocess.CalledProcessError: 198 | continue 199 | else: 200 | break 201 | 202 | yield port 203 | 204 | # Kill server 205 | process.kill() 206 | process.communicate() 207 | 208 | # Restore environment 209 | for key, value in backup_env.items(): 210 | os.environ[key] = value 211 | 212 | # Delete server root 213 | if root.exists(): 214 | shutil.rmtree(root) 215 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | """Test suite for the commands module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | import pyforce 10 | 11 | if TYPE_CHECKING: 12 | from conftest import ClientFactory, FileFactory, UserFactory 13 | 14 | 15 | def test_get_user_returns_expected_user(server: str, user_factory: UserFactory) -> None: 16 | """It returns the expected `User` instance.""" 17 | user_factory("foo") 18 | connection = pyforce.Connection(port=server) 19 | user = pyforce.get_user(connection, "foo") 20 | assert isinstance(user, pyforce.User) 21 | assert user.name == "foo" 22 | 23 | 24 | def test_get_user_raise_user_not_found(server: str) -> None: 25 | """It raises an error when user does not exists.""" 26 | connection = pyforce.Connection(port=server) 27 | with pytest.raises(pyforce.UserNotFoundError): 28 | pyforce.get_user(connection, "foo") 29 | 30 | 31 | def test_get_client_returns_expected_client( 32 | server: str, 33 | client_factory: ClientFactory, 34 | ) -> None: 35 | """It returns the expected `Client` instance.""" 36 | client_factory("foo") 37 | connection = pyforce.Connection(port=server) 38 | user = pyforce.get_client(connection, "foo") 39 | assert isinstance(user, pyforce.Client) 40 | assert user.name == "foo" 41 | 42 | 43 | def test_get_client_raise_client_not_found(server: str) -> None: 44 | """It raises an error when client does not exists.""" 45 | connection = pyforce.Connection(port=server) 46 | with pytest.raises(pyforce.ClientNotFoundError): 47 | pyforce.get_client(connection, "foo") 48 | 49 | 50 | def test_get_change_return_expected_changelist( 51 | server: str, 52 | client: pyforce.Client, 53 | ) -> None: 54 | """It returns a Change instance of expected change.""" 55 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 56 | change_info = pyforce.create_changelist(connection, "Foo") 57 | 58 | change = pyforce.get_change(connection, change_info.change) 59 | assert isinstance(change, pyforce.Change) 60 | assert change.change == change_info.change 61 | 62 | 63 | def test_get_change_raise_change_not_found( 64 | server: str, 65 | client: pyforce.Client, 66 | ) -> None: 67 | """It raises an error when change does not exists.""" 68 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 69 | with pytest.raises(pyforce.ChangeUnknownError): 70 | pyforce.get_change(connection, 123) 71 | 72 | 73 | def test_create_changelist_return_change_info( 74 | server: str, 75 | client: pyforce.Client, 76 | ) -> None: 77 | """It create a new changelist and return it.""" 78 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 79 | info = pyforce.create_changelist(connection, "Foo Bar") 80 | assert isinstance(info, pyforce.ChangeInfo) 81 | assert info.description.strip() == "Foo Bar" 82 | 83 | 84 | def test_create_changelist_return_correct_change( 85 | server: str, 86 | client: pyforce.Client, 87 | ) -> None: 88 | """When multiple changelists already exists, it return the correct one.""" 89 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 90 | pyforce.create_changelist(connection, "Foo") 91 | info = pyforce.create_changelist(connection, "Bar") 92 | assert info.description.strip() == "Bar" 93 | 94 | 95 | def test_create_changelist_return_existing_changelist( 96 | server: str, 97 | client: pyforce.Client, 98 | ) -> None: 99 | """The changelist is actually created on the server.""" 100 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 101 | info = pyforce.create_changelist(connection, "Foo") 102 | change = pyforce.get_change(connection, info.change) 103 | assert change.change == info.change 104 | assert change.description == info.description 105 | 106 | 107 | def test_create_changelist_contain_no_files( 108 | server: str, 109 | file_factory: FileFactory, 110 | client: pyforce.Client, 111 | ) -> None: 112 | """It does not includes files from default changelist.""" 113 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 114 | file = file_factory(root=client.root, content="foo") 115 | pyforce.add(connection, [str(file)]) 116 | info = pyforce.create_changelist(connection, "Foo") 117 | change = pyforce.get_change(connection, info.change) 118 | assert change.files == [] 119 | 120 | 121 | # TODO: test_changes 122 | 123 | 124 | def test_add_return_added_file( 125 | server: str, 126 | file_factory: FileFactory, 127 | client: pyforce.Client, 128 | ) -> None: 129 | """It returns the added files.""" 130 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 131 | file = file_factory(root=client.root, content="foo") 132 | messages, infos = pyforce.add(connection, [str(file)]) 133 | assert not messages 134 | assert len(infos) == 1 135 | assert isinstance(infos[0], pyforce.ActionInfo) 136 | assert infos[0].client_file == str(file) 137 | 138 | 139 | def test_add_empty_file_return_addinfo( 140 | server: str, 141 | file_factory: FileFactory, 142 | client: pyforce.Client, 143 | ) -> None: 144 | """It returns info object with correct information.""" 145 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 146 | file = file_factory(root=client.root, content="") 147 | messages, _ = pyforce.add(connection, [str(file)]) 148 | assert len(messages) == 1 149 | assert isinstance(messages[0], pyforce.ActionMessage) 150 | assert messages[0].message == "empty, assuming text." 151 | 152 | 153 | def test_add_to_changelist( 154 | server: str, 155 | file_factory: FileFactory, 156 | client: pyforce.Client, 157 | ) -> None: 158 | """It open file for add in specified changelist.""" 159 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 160 | 161 | change_info = pyforce.create_changelist(connection, "Foo") 162 | 163 | file = file_factory(root=client.root, content="foobar") 164 | _, infos = pyforce.add(connection, [str(file)], changelist=change_info.change) 165 | 166 | change = pyforce.get_change(connection, change_info.change) 167 | assert infos[0].depot_file == change.files[0] 168 | 169 | 170 | def test_add_preview_does_not_add_file_to_changelist( 171 | server: str, 172 | file_factory: FileFactory, 173 | client: pyforce.Client, 174 | ) -> None: 175 | """Running add in preview mode does not add file to changelist.""" 176 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 177 | change_info = pyforce.create_changelist(connection, "Foo") 178 | 179 | pyforce.add( 180 | connection, 181 | [str(file_factory(root=client.root, content="foobar"))], 182 | changelist=change_info.change, 183 | preview=True, 184 | ) 185 | 186 | change = pyforce.get_change(connection, change_info.change) 187 | assert change.files == [] 188 | 189 | 190 | def test_edit_return_edited_files( 191 | server: str, 192 | file_factory: FileFactory, 193 | client: pyforce.Client, 194 | ) -> None: 195 | """It returns the edited files.""" 196 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 197 | file = file_factory(root=client.root, content="foo") 198 | 199 | pyforce.add(connection, [str(file)]) 200 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 201 | messages, infos = pyforce.edit(connection, [str(file)]) 202 | 203 | assert not messages 204 | assert len(infos) == 1 205 | assert isinstance(infos[0], pyforce.ActionInfo) 206 | assert infos[0].client_file == str(file) 207 | 208 | 209 | def test_edit_add_file_to_changelist( 210 | server: str, 211 | file_factory: FileFactory, 212 | client: pyforce.Client, 213 | ) -> None: 214 | """It open file for edit in specified changelist.""" 215 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 216 | 217 | file = file_factory(root=client.root, content="foo") 218 | change_info = pyforce.create_changelist(connection, "Foo") 219 | 220 | pyforce.add(connection, [str(file)]) 221 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 222 | _, infos = pyforce.edit(connection, [str(file)], changelist=change_info.change) 223 | 224 | change = pyforce.get_change(connection, change_info.change) 225 | assert infos[0].depot_file == change.files[0] 226 | 227 | 228 | def test_edit_preview_does_not_add_file_to_changelist( 229 | server: str, 230 | file_factory: FileFactory, 231 | client: pyforce.Client, 232 | ) -> None: 233 | """Running edit in preview mode does not open file to changelist.""" 234 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 235 | 236 | change_info = pyforce.create_changelist(connection, "Foo") 237 | 238 | file = file_factory(root=client.root, content="foo") 239 | pyforce.add(connection, [str(file)]) 240 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 241 | 242 | pyforce.edit(connection, [str(file)], changelist=change_info.change, preview=True) 243 | 244 | change = pyforce.get_change(connection, change_info.change) 245 | assert change.files == [] 246 | 247 | 248 | def test_delete_return_deleted_files( 249 | server: str, 250 | file_factory: FileFactory, 251 | client: pyforce.Client, 252 | ) -> None: 253 | """It returns the edited files.""" 254 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 255 | file = file_factory(root=client.root, content="foo") 256 | 257 | pyforce.add(connection, [str(file)]) 258 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 259 | messages, infos = pyforce.delete(connection, [str(file)]) 260 | 261 | assert not messages 262 | assert len(infos) == 1 263 | assert isinstance(infos[0], pyforce.ActionInfo) 264 | assert infos[0].client_file == str(file) 265 | 266 | 267 | def test_delete_add_file_to_changelist( 268 | server: str, 269 | file_factory: FileFactory, 270 | client: pyforce.Client, 271 | ) -> None: 272 | """It open file for deletion to specified changelist.""" 273 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 274 | 275 | file = file_factory(root=client.root, content="foo") 276 | change_info = pyforce.create_changelist(connection, "Foo") 277 | 278 | pyforce.add(connection, [str(file)]) 279 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 280 | _, infos = pyforce.delete(connection, [str(file)], changelist=change_info.change) 281 | 282 | change = pyforce.get_change(connection, change_info.change) 283 | assert infos[0].depot_file == change.files[0] 284 | 285 | 286 | def test_delete_preview_does_not_add_file_to_changelist( 287 | server: str, 288 | file_factory: FileFactory, 289 | client: pyforce.Client, 290 | ) -> None: 291 | """Running delete in preview mode does not open file to changelist.""" 292 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 293 | 294 | change_info = pyforce.create_changelist(connection, "Foo") 295 | 296 | file = file_factory(root=client.root, content="foo") 297 | pyforce.add(connection, [str(file)]) 298 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 299 | 300 | pyforce.delete(connection, [str(file)], changelist=change_info.change, preview=True) 301 | 302 | change = pyforce.get_change(connection, change_info.change) 303 | assert change.files == [] 304 | 305 | 306 | def test_sync_return_synced_files( 307 | server: str, 308 | file_factory: FileFactory, 309 | client: pyforce.Client, 310 | ) -> None: 311 | """It sync files and return them.""" 312 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 313 | file = file_factory(root=client.root, content="foo") 314 | 315 | pyforce.add(connection, [str(file)]) 316 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 317 | pyforce.p4(connection, ["sync", "-f", f"{file}#0"]) 318 | 319 | synced_files = pyforce.sync(connection, [str(file)]) 320 | 321 | assert len(synced_files) == 1 322 | assert isinstance(synced_files[0], pyforce.Sync) 323 | assert synced_files[0].client_file == str(file) 324 | assert synced_files[0].revision == 1 325 | 326 | 327 | def test_sync_skip_up_to_date_file( 328 | server: str, 329 | file_factory: FileFactory, 330 | client: pyforce.Client, 331 | ) -> None: 332 | """Is skip up to date files without raising error.""" 333 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 334 | file = file_factory(root=client.root, content="foo") 335 | 336 | pyforce.add(connection, [str(file)]) 337 | pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) 338 | 339 | synced_files = pyforce.sync(connection, [str(file)]) 340 | assert len(synced_files) == 0 341 | 342 | 343 | def test_fstat_return_fstat_instance( 344 | server: str, 345 | client: pyforce.Client, 346 | file_factory: FileFactory, 347 | ) -> None: 348 | """It return expected object type.""" 349 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 350 | path = file_factory(root=client.root, content="foo") 351 | 352 | pyforce.add(connection, [str(path)]) 353 | pyforce.p4(connection, ["submit", "-d", "Foo", str(path)]) 354 | 355 | fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=False)) 356 | assert isinstance(fstats[0], pyforce.FStat) 357 | 358 | 359 | def test_fstat_return_remote_file( 360 | server: str, 361 | client: pyforce.Client, 362 | file_factory: FileFactory, 363 | ) -> None: 364 | """It list remote file.""" 365 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 366 | path = file_factory(root=client.root, content="foo") 367 | 368 | pyforce.add(connection, [str(path)]) 369 | pyforce.p4(connection, ["submit", "-d", "Foo", str(path)]) 370 | 371 | fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=False)) 372 | assert len(fstats) == 1 373 | assert fstats[0].client_file == str(path) 374 | assert fstats[0].head 375 | assert fstats[0].head.revision # NOTE: check is in depot 376 | assert fstats[0].have_rev # NOTE: check is client 377 | 378 | 379 | def test_fstat_return_deleted_file( 380 | server: str, 381 | client: pyforce.Client, 382 | file_factory: FileFactory, 383 | ) -> None: 384 | """It list deleted file if include_deleted is True.""" 385 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 386 | path = file_factory(root=client.root, content="foo") 387 | 388 | pyforce.add(connection, [str(path)]) 389 | pyforce.p4(connection, ["submit", "-d", "Adding foo", str(path)]) 390 | pyforce.p4(connection, ["delete", str(path)]) 391 | pyforce.p4(connection, ["submit", "-d", "Deleting Foo", str(path)]) 392 | 393 | fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=True)) 394 | assert len(fstats) == 1 395 | assert fstats[0].client_file == str(path) 396 | 397 | 398 | def test_fstat_ignore_local_file( 399 | server: str, 400 | client: pyforce.Client, 401 | file_factory: FileFactory, 402 | ) -> None: 403 | """It ignore local files.""" 404 | connection = pyforce.Connection(port=server, user=client.owner, client=client.name) 405 | path = file_factory(root=client.root, content="foo") 406 | 407 | fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=False)) 408 | assert len(fstats) == 0 409 | 410 | 411 | # TODO: test_get_revisions 412 | --------------------------------------------------------------------------------