├── .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 |
--------------------------------------------------------------------------------