From 2c5c706cc76be92e78cd0ca132e8335e7c182032 Mon Sep 17 00:00:00 2001 From: Thibaud Gambier Date: Sat, 30 Mar 2024 14:40:40 +0100 Subject: [PATCH] initial commit --- .dockerignore | 6 + .github/workflows/release.yml | 91 ++++++ .github/workflows/style.yml | 45 +++ .github/workflows/tests.yml | 93 ++++++ .gitignore | 8 + CONTRIBUTING.md | 30 ++ Dockerfile | 38 +++ LICENSE | 21 ++ Makefile | 118 ++++++++ README.md | 128 +++++++++ docs/changelog.md | 1 + docs/conf.py | 65 +++++ docs/contributing.md | 2 + docs/index.md | 12 + docs/reference.rst | 121 ++++++++ pyproject.toml | 147 ++++++++++ src/pyforce/__init__.py | 6 + src/pyforce/commands.py | 517 ++++++++++++++++++++++++++++++++++ src/pyforce/exceptions.py | 60 ++++ src/pyforce/models.py | 495 ++++++++++++++++++++++++++++++++ src/pyforce/utils.py | 197 +++++++++++++ tests/conftest.py | 214 ++++++++++++++ tests/test_commands.py | 411 +++++++++++++++++++++++++++ 23 files changed, 2826 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/style.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/changelog.md create mode 100644 docs/conf.py create mode 100644 docs/contributing.md create mode 100644 docs/index.md create mode 100644 docs/reference.rst create mode 100644 pyproject.toml create mode 100644 src/pyforce/__init__.py create mode 100644 src/pyforce/commands.py create mode 100644 src/pyforce/exceptions.py create mode 100644 src/pyforce/models.py create mode 100644 src/pyforce/utils.py create mode 100644 tests/conftest.py create mode 100644 tests/test_commands.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a83bd88 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +/.venv +__pycache__ +*.egg-info +*.pyc +/dist +/.mypy_cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0c34283 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,91 @@ +name: Release + +on: + push: + branches: [main] + tags: ["*"] + pull_request: + branches: [main] + +jobs: + build: + name: Build package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install requirements + run: | + python -Im pip install --upgrade pip + python -Im pip install build check-wheel-contents + - name: Build package + run: python -Im build + - name: Check wheel content + run: check-wheel-contents dist/*.whl + - name: Print package contents summary + run: | + echo -e '
SDist Contents\n' >> $GITHUB_STEP_SUMMARY + tar -tf dist/*.tar.gz | tree -a --fromfile . | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY + echo -e '
\n' >> $GITHUB_STEP_SUMMARY + echo -e '
Wheel Contents\n' >> $GITHUB_STEP_SUMMARY + unzip -Z1 dist/*.whl | tree -a --fromfile . | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY + echo -e '
\n' >> $GITHUB_STEP_SUMMARY + - uses: actions/upload-artifact@v4 + with: + name: dist + path: ./dist + + # Draft a new release on tagged commit on main. + draft-release: + name: Draft release + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + needs: [build] + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + - name: Draft a new release + run: > + gh release create --draft --repo ${{ github.repository }} + ${{ github.ref_name }} + dist/* + env: + GH_TOKEN: ${{ github.token }} + + # Publish to PyPI on tagged commit on main. + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + needs: [build] + environment: + name: publish-pypi + url: https://pypi.org/project/pyforce-p4/${{ github.ref_name }} + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + # Publish to Test PyPI on every commit on main. + publish-test-pypi: + name: Publish to Test PyPI + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [build] + environment: publish-test-pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + - name: Upload package to Test PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 0000000..bf8cef8 --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,45 @@ +name: Code style + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Cancel concurent in-progress jobs or run on pull_request +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + ruff-lint: + name: Ruff lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install requirements + run: | + python -Im pip install --upgrade pip + python -Im pip install ruff + - name: Run Ruff linter + run: python -Im ruff check --output-format=github src tests + + ruff-format: + name: Ruff format diff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install requirements + run: | + python -Im pip install --upgrade pip + python -Im pip install ruff + - name: Run Ruff formatter + run: python -Im ruff format --diff src tests diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..540b798 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,93 @@ +name: Tests and type checking + +on: + push: + branches: + - main + pull_request: + branches: + - main + +# TODO: pages +# TODO: build +# TODO: publish + +jobs: + mypy: + name: Mypy ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install requirements + run: | + python -Im pip install --upgrade pip + python -Im pip install .[mypy] + - name: Run mypy + run: | + python -Im mypy src tests + + tests: + name: Tests ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build --build-arg="PYTHON_VERSION=${{ matrix.python-version }}" -t local . + - name: Run tests + run: | + docker run --name test local "python -m coverage run -p -m pytest && mkdir cov && mv .coverage.* cov" + docker cp test:/app/cov/. . + - name: Upload coverage data + uses: actions/upload-artifact@v4 + with: + name: coverage-data-${{ matrix.python-version }} + path: .coverage.* + if-no-files-found: ignore + + coverage: + name: Combine and report coverage + runs-on: ubuntu-latest + needs: tests + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Download coverage data + uses: actions/download-artifact@v4 + with: + pattern: coverage-data-* + merge-multiple: true + - name: Install requirements + run: | + python -Im pip install --upgrade pip + python -Im pip install coverage + - name: Combine coverage and report + run: | + python -Im coverage combine + # Report in summary + python -Im coverage report --show-missing --skip-covered --skip-empty --format=markdown >> $GITHUB_STEP_SUMMARY + # Report in console + python -Im coverage report --show-missing --skip-covered --skip-empty + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..020b068 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.venv +__pycache__ +*.egg-info +/.python-version +*.pyc +/dist +/docs/_build +/.coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6209612 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +## Makefile commands + +This project include a [Makefile](https://www.gnu.org/software/make/) +containing the most common commands used when developing. + +```text +$ make help +build Build project +clean Clear local caches and build artifacts +doc Build documentation +formatdiff Show what the formatting would look like +help Display this message +install Install the package and dependencies for local development +interactive Run an interactive docker container +linkcheck Check all external links in docs for integrity +lint Run linter +mypy Perform type-checking +serve Serve documentation at http://127.0.0.1:8000 +tests Run the tests with coverage in a docker container +uninstall Remove development environment +``` + +## Running the tests + +The tests require a new `p4d` server running in the backgound. +To simplify the development, tests are runned in a docker container generated from this [Dockerfile](Dockerfile). + +This target `make tests` target build the container and run the tests + coverage inside. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e5f80e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Installation: +# https://www.perforce.com/manuals/p4sag/Content/P4SAG/install.linux.packages.install.html + +# Post-installation configuration: +# https://www.perforce.com/manuals/p4sag/Content/P4SAG/install.linux.packages.configure.html + +# Example perforce server: +# https://github.com/ambakshi/docker-perforce/tree/master/perforce-server + +# Dockerfile from sourcegraph: +# https://github.com/sourcegraph/helix-docker/tree/main + +# Making a Perforce Server with Docker compose +# https://aricodes.net/posts/perforce-server-with-docker/ +ARG PYTHON_VERSION=3.11 +FROM python:$PYTHON_VERSION + +# Update Ubuntu and add Perforce Package Source +# Add the perforce public key to our keyring +# Add perforce repository to our APT config +RUN apt-get update && \ + apt-get install -y wget gnupg2 && \ + wget -qO - https://package.perforce.com/perforce.pubkey | apt-key add - && \ + echo "deb http://package.perforce.com/apt/ubuntu focal release" > /etc/apt/sources.list.d/perforce.list && \ + apt-get update + +# Install helix-p4d, which installs p4d, p4, p4dctl, and a configuration script. +RUN apt-get update && apt-get install -y helix-p4d + +WORKDIR /app + +RUN python -m pip install pytest coverage + +COPY . . + +RUN python -m pip install --editable . + +ENTRYPOINT ["/bin/sh", "-c"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5aa5cb8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Thibaud Gambier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..646c68e --- /dev/null +++ b/Makefile @@ -0,0 +1,118 @@ +venv = .venv +sources = src tests + +DOCS_BUILDDIR = docs/_build + +ifeq ($(OS), Windows_NT) +python = $(venv)\Scripts\python.exe +else +python = $(venv)/bin/python +endif + +.PHONY: uninstall ## Remove development environment +ifeq ($(OS), Windows_NT) +uninstall: + if exist $(venv) rd /q /s $(venv) + for /d /r %%g in (src\*.egg-info) do rd /q /s "%%g" 2>nul || break +else +uninstall: + rm -rf $(venv) + rm -rf src/*.egg-info +endif + +ifeq ($(OS), Windows_NT) +$(venv): pyproject.toml + @$(MAKE) --no-print-directory uninstall + python -m venv $(venv) + $(python) -m pip install build --editable .[dev] +else +$(venv): pyproject.toml + @$(MAKE) --no-print-directory uninstall + python -m venv $(venv) + $(python) -m pip install build --editable .[dev] + touch $(venv) +endif + +.PHONY: install ## Install the package and dependencies for local development +install: + @$(MAKE) --no-print-directory --always-make $(venv) + +.PHONY: clean ## Clear local caches and build artifacts +ifeq ($(OS), Windows_NT) +clean: + del /f /q /s *.pyc >nul 2>nul + del /f /q .coverage >nul 2>nul + del /f /q .coverage.* >nul 2>nul + del /f /q result.xml >nul 2>nul + del /f /q coverage.xml >nul 2>nul + rd /q /s dist 2>nul || break + rd /q /s .ruff_cache 2>nul || break + for /d /r %%g in (__pycache__) do rd /q /s "%%g" 2>nul || break +else +clean: + rm -rf `find . -type f -name '*.pyc'` + rm -f .coverage + rm -f .coverage.* + rm -f result.xml + rm -f coverage.xml + rm -rf dist + rm -rf .ruff_cache + rm -rf `find . -name __pycache__` + rm -rf $(DOCS_BUILDDIR) +endif + +.PHONY: tests ## Run the tests with coverage in a docker container +tests: $(venv) + -docker stop pyforce-test && docker rm pyforce-test + docker build --quiet --platform linux/amd64 -t pyforce . + docker run --name pyforce-test pyforce "python -m coverage run -m pytest" + docker cp pyforce-test:/app/.coverage .coverage + $(python) -m coverage report --show-missing --skip-covered --skip-empty + +.PHONY: interactive ## Run an interactive docker container +interactive: + docker build --platform linux/amd64 -t pyforce . + docker run --rm -it pyforce -c "mkdir -p /depot && p4d -r /depot -r localhost:1666 --daemonsafe -L /app/p4dlogs && /bin/sh" + +.PHONY: lint ## Run linter +lint: $(venv) + $(python) -m ruff check $(sources) + +.PHONY: formatdiff ## Show what the formatting would look like +formatdiff: $(venv) + $(python) -m ruff format --diff $(sources) + +.PHONY: mypy ## Perform type-checking +mypy: $(venv) + $(python) -m mypy $(sources) + +.PHONY: build ## Build project +build: $(venv) + $(python) -m build + +.PHONY: doc ## Build documentation +doc: $(venv) + $(python) -m sphinx -b html -a docs $(DOCS_BUILDDIR) + +.PHONY: serve ## Serve documentation at http://127.0.0.1:8000 +serve: $(venv) + $(python) -m sphinx_autobuild -b html -a --watch README.md --watch src -vvv docs $(DOCS_BUILDDIR) + +.PHONY: linkcheck ## Check all external links in docs for integrity +linkcheck: $(venv) + $(python) -m sphinx -b linkcheck -a docs $(DOCS_BUILDDIR)/linkcheck + +.PHONY: help ## Display this message +ifeq ($(OS), Windows_NT) +help: + @setlocal EnableDelayedExpansion \ + && for /f "tokens=2,* delims= " %%g in ('findstr /R /C:"^\.PHONY: .* ##.*$$" Makefile') do \ + set name=%%g && set "name=!name! " && set "name=!name:~0,14!" \ + && set desc=%%h && set "desc=!desc:~3!" \ + && echo !name!!desc! +else +help: + @grep -E '^.PHONY: .*?## .*$$' Makefile \ + | awk 'BEGIN {FS = ".PHONY: |## "}; {printf "%-14s %s\n", $$2, $$3}' \ + | sort +endif diff --git a/README.md b/README.md new file mode 100644 index 0000000..807c8ba --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Pyforce + +[![License][license-badge]][pyforce-license] +[![PyPi - Python Version][python-version-badge]][pyforce-pypi] +[![PyPi - Version][version-badge]][pyforce-pypi] +[![Linter - Ruff][ruff-badge]][ruff-repo] + +Python wrapper for Perforce p4 command-line client. + +## Features + +- Python wrapper for the `p4` command using [marshal](https://docs.python.org/3/library/marshal.html). +- Built with [Pydantic](https://github.com/pydantic/pydantic). +- Fully typed. +- Built for scripting. + +## Installation + +```bash +python -m pip install pyforce-p4 +``` + +## Quickstart + +```python +import pyforce + +connection = pyforce.Connection(host="localhost:1666", user="foo", client="my-client") + +# Create a new file in our client +file = "/home/foo/my-client/bar.txt" +fp = open(file, "w") +fp.write("bar") +fp.close() + +# Run 'p4 add', open our file for addition to the depot +_, infos = pyforce.add(connection, [file]) +print(infos[0]) +""" +ActionInfo( + action='add', + client_file='/home/foo/my-client/bar.txt', + depot_file='//my-depot/my-stream/bar.txt', + file_type='text', + work_rev=1 +) +""" + +# Run 'p4 submit', submitting our local file +pyforce.p4(connection, ["submit", "-d", "Added bar.txt", file]) + +# Run 'p4 fstat', listing all files in depot +fstats = list(pyforce.fstat(connection, ["//..."])) +print(fstats[0]) +""" +FStat( + client_file='/home/foo/my-client/bar.txt', + depot_file='//my-depot/my-stream/bar.txt', + head=HeadInfo( + action=, + change=2, + revision=1, + file_type='text', + time=datetime.datetime(2024, 3, 29, 13, 56, 57, tzinfo=datetime.timezone.utc), + mod_time=datetime.datetime(2024, 3, 29, 13, 56, 11, tzinfo=datetime.timezone.utc) + ), + have_rev=1, + is_mapped=True, + others_open=None +) +""" +``` + +The goal of Pyforce is not to be exhaustive. +It focuses on the most common `p4` commands, +but can execute more complexe commands by using `pyforce.p4`. + +For example, Pyforce does not have a function to create a new client workspace, +but it is possible to create one with `pyforce.p4`. + +```python +import pyforce + +connection = pyforce.Connection(port="localhost:1666", user="foo") + +# Create client +command = ["client", "-o", "-S", "//my-depot/my-stream", "my-client"] +data = pyforce.p4(connection, command)[0] +data["Root"] = "/home/foo/my-client" +pyforce.p4(connection, ["client", "-i"], stdin=data) + +# Get created client +client = pyforce.get_client(connection, "my-client") +print(client) +""" +Client( + name='my-client', + host='5bb1735f73fc', + owner='root', + root=PosixPath('/home/foo/my-client'), + stream='//my-depot/my-stream', + type=, + views=[View(left='//my-depot/my-stream/...', right='//my-client/...')] +) +""" +``` + + + +## Contributing + +For guidance on setting up a development environment and contributing to `Pyforce`, +see the [Contributing](https://github.com/tahv/pyforce/blob/main/CONTRIBUTING.md) section. + + + +[license-badge]: https://img.shields.io/github/license/tahv/pyforce +[ruff-badge]: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v1.json +[version-badge]: https://img.shields.io/pypi/v/pyforce-p4?logo=pypi&logoColor=white +[python-version-badge]: https://img.shields.io/pypi/pyversions/pyforce-p4?logo=python&logoColor=white + +[pyforce-license]: https://github.com/tahv/pyforce/blob/main/LICENSE +[ruff-repo]: https://github.com/astral-sh/ruff +[pyforce-pypi]: https://pypi.org/project/pyforce-p4 diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +# Changelog diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3da42be --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,65 @@ +"""Configuration file for the Sphinx documentation builder. + +Documentation: + https://www.sphinx-doc.org/en/master/usage/configuration.html +""" +import importlib.metadata +import sys +from pathlib import Path + +PROJECT_ROOT_DIR = Path(__file__).parents[1].resolve() +SRC_DIR = (PROJECT_ROOT_DIR / "src").resolve() +sys.path.append(str(SRC_DIR)) + +# -- Project information ----------------------------------------------------- + +project = "Pyforce" +author = "Thibaud Gambier" +copyright = f"2024, {author}" # noqa: A001 +release = importlib.metadata.version("pyforce-p4") +version = ".".join(release.split(".", 2)[0:2]) + +# -- General configuration --------------------------------------------------- + +# fmt: off +extensions = [ + "myst_parser", # markdown + "sphinx.ext.autodoc", # docstring + "sphinxcontrib.autodoc_pydantic", # docstring / pydantic compatibility + "sphinx.ext.napoleon", # google style docstring + "sphinx.ext.intersphinx", # cross-projects references + "enum_tools.autoenum", +] +# fmt: on + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +maximum_signature_line_length = 80 +default_role = "any" + +# -- Extensions configuration ------------------------------------------------ + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +autodoc_pydantic_model_show_json = False +autodoc_pydantic_model_show_config_summary = False +autodoc_pydantic_model_show_validator_summary = False +autodoc_pydantic_model_show_field_summary = False +autodoc_pydantic_field_show_constraints = False +autodoc_pydantic_field_list_validators = False + +autodoc_pydantic_field_show_default = False +autodoc_pydantic_field_show_required = False + +autodoc_class_signature = "separated" +autodoc_default_options = { + "exclude-members": "__new__", +} + +# -- Options for HTML output ------------------------------------------------- + +html_theme = "furo" +html_static_path = ["_static"] +html_title = "Pyforce" diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..78caf34 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,2 @@ +```{include} ../CONTRIBUTING.md +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d1b38e2 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,12 @@ +```{include} ../README.md +``` + +```{toctree} +:maxdepth: 2 +:hidden: + +self +reference +changelog +contributing +``` diff --git a/docs/reference.rst b/docs/reference.rst new file mode 100644 index 0000000..6b4fbcc --- /dev/null +++ b/docs/reference.rst @@ -0,0 +1,121 @@ +.. _p4 user: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_user.html +.. _p4 client: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_client.html +.. _p4 change: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_change.html +.. _p4 add: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_add.html +.. _p4 filelog: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_filelog.html +.. _p4 fstat: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_fstat.html +.. _p4 edit: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_edit.html +.. _p4 delete: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_delete.html +.. _p4 changes: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_changes.html +.. _File specifications: https://www.perforce.com/manuals/cmdref/Content/CmdRef/filespecs.html +.. _View specification: https://www.perforce.com/manuals/cmdref/Content/CmdRef/views.html + + +API Reference +=============== + +Commands +-------- + +.. autofunction:: pyforce.p4 +.. autofunction:: pyforce.login + +.. autofunction:: pyforce.get_user +.. autofunction:: pyforce.get_client +.. autofunction:: pyforce.get_change +.. autofunction:: pyforce.get_revisions +.. autofunction:: pyforce.create_changelist +.. autofunction:: pyforce.add +.. autofunction:: pyforce.edit +.. autofunction:: pyforce.delete +.. autofunction:: pyforce.sync +.. autofunction:: pyforce.fstat + +User +---- + +.. autopydantic_model:: pyforce.User + +.. autoenum:: pyforce.UserType + :members: + +.. autoenum:: pyforce.AuthMethod + :members: + +Client +------ + +.. autopydantic_model:: pyforce.Client + +.. autoclass:: pyforce.View + :members: + +.. autoclass:: pyforce.ClientOptions + :members: + +.. autoenum:: pyforce.ClientType + :members: + +.. autoenum:: pyforce.SubmitOptions + :members: + + +Change +------ + +.. autopydantic_model:: pyforce.Change +.. autopydantic_model:: pyforce.ChangeInfo + +.. autoenum:: pyforce.ChangeType + :members: + +.. autoenum:: pyforce.ChangeStatus + :members: + + +Action +------ + +.. autopydantic_model:: pyforce.ActionInfo + +.. autoenum:: pyforce.Action + :members: + +.. autoclass:: pyforce.ActionMessage + :members: + +Revision +-------- + +.. autopydantic_model:: pyforce.Revision + +Sync +---- + +.. autopydantic_model:: pyforce.Sync + +FStat +----- + +.. autopydantic_model:: pyforce.FStat +.. autopydantic_model:: pyforce.HeadInfo +.. autoclass:: pyforce.OtherOpen + +Exceptions +---------- + +.. autoexception:: pyforce.AuthenticationError +.. autoexception:: pyforce.ConnectionExpiredError +.. autoexception:: pyforce.CommandExecutionError + +Other +----- + +.. autoclass:: pyforce.Connection + :members: + +.. autoenum:: pyforce.MessageSeverity + :members: + +.. autoenum:: pyforce.MarshalCode + :members: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8c9e768 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,147 @@ +#:schema https://json.schemastore.org/pyproject.json +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "pyforce-p4" +description = "Python wrapper for Perforce p4 command-line client" +readme = "README.md" +license = "MIT" +authors = [{ name = "Thibaud Gambier" }] +requires-python = ">=3.7" +dependencies = ["pydantic", "typing_extensions"] +dynamic = ["version"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Typing :: Typed", +] + +[project.urls] +Source = "https://github.com/tahv/pyforce" +# Documentation = "" + +[project.optional-dependencies] +tests = ["pytest", "coverage"] +style = ["ruff"] +mypy = ["mypy", "pytest"] +doc = [ + "sphinx", + "sphinx-autobuild", + "furo", + "myst-parser", + "autodoc_pydantic", + "enum_tools[sphinx]", +] +dev = ["pyforce-p4[style,mypy,doc]"] + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" + +[tool.hatch.build.targets.wheel] +packages = ["src/pyforce"] + +[tool.pytest.ini_options] +addopts = "--doctest-modules" +testpaths = ["src", "tests"] + +[tool.coverage.run] +source = ["src/"] +branch = true + +[tool.coverage.report] +show_missing = true +skip_covered = true +exclude_lines = [ + "# pragma: no cover", + "if (False|0|TYPE_CHECKING):", + "if __name__ == ['\"]__main__['\"]:", +] + +[tool.coverage.paths] +source = ["src/", "*/src"] + +[tool.mypy] +plugins = ["pydantic.mypy"] +disallow_untyped_defs = true +check_untyped_defs = true +disallow_any_unimported = true +no_implicit_optional = true +warn_return_any = true +warn_unused_ignores = true +warn_redundant_casts = true +show_error_codes = true +# disallow_any_generics = true +# implicit_reexport = false + +[tool.pydantic-mypy] +init_typed = true +warn_required_dynamic_aliases = true + +[tool.ruff] +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # ANN101: Missing type annotation for `self` in method + "ANN101", + # ANN102: Missing type annotation for `cls` in classmethod + "ANN102", + # D107: Missing docstring in `__init__` + "D107", + # D105: Missing docstring in magic method + "D105", + # S603: `subprocess` call: check for execution of untrusted input + "S603", + # TD002: Missing author in TODO + "TD002", + # TD003: Missing issue link on the line following this TODO + "TD003", + # FIX002: Line contains TODO, consider resolving the issue + "FIX002", + + # Compatibility + + # UP006: (3.9) - Use `list` instead of `List` for type annotation + "UP006", + # UP007: (3.10) - Use `X | Y` for type annotations + "UP007", +] +unfixable = [ + # ERA001: Found commented-out code + "ERA001", + # F401: Unused import + "F401", +] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*" = [ + # PLR2004: Magic value used in comparison, consider replacing with a constant variable + "PLR2004", + # S101: Use of assert detected + "S101", + # S607: Starting a process with a partial executable path + "S607", +] +"__init__.py" = [ + # F403: `from ... import *` used; unable to detect undefined names + "F403", +] diff --git a/src/pyforce/__init__.py b/src/pyforce/__init__.py new file mode 100644 index 0000000..7439231 --- /dev/null +++ b/src/pyforce/__init__.py @@ -0,0 +1,6 @@ +"""A Python perforce API.""" + +from pyforce.commands import * +from pyforce.exceptions import * +from pyforce.models import * +from pyforce.utils import * diff --git a/src/pyforce/commands.py b/src/pyforce/commands.py new file mode 100644 index 0000000..d5012af --- /dev/null +++ b/src/pyforce/commands.py @@ -0,0 +1,517 @@ +"""Pyforce commands.""" + +from __future__ import annotations + +import marshal +import re +import subprocess +from typing import Iterator + +from pyforce.exceptions import ( + AuthenticationError, + ChangeUnknownError, + ClientNotFoundError, + CommandExecutionError, + ConnectionExpiredError, + UserNotFoundError, +) +from pyforce.models import ( + ActionInfo, + ActionMessage, + Change, + ChangeInfo, + ChangeStatus, + Client, + FStat, + Revision, + Sync, + User, +) +from pyforce.utils import ( + Connection, + MarshalCode, + MessageSeverity, + PerforceDict, + extract_indexed_values, + log, +) + +# TODO: submit_changelist +# TODO: edit_changelist +# TODO: move: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_move.html +# TODO: opened + +__all__ = [ + "get_user", + "get_client", + "get_change", + "get_revisions", + "create_changelist", + "add", + "edit", + "delete", + "sync", + "login", + "fstat", + "p4", +] + + +def get_user(connection: Connection, user: str) -> User: + """Get user specification. + + Command: + `p4 user`_. + + Args: + connection: Perforce connection. + user: Perforce username. + """ + data = p4(connection, ["user", "-o", user])[0] + if "Update" not in data: + msg = f"User {user!r} does not exists" + raise UserNotFoundError(msg) + return User(**data) # type: ignore[arg-type] + + +def get_client(connection: Connection, client: str) -> Client: + """Get client workspace specification. + + Command: + `p4 client`_. + + Args: + connection: Perforce connection. + client: Perforce client name. + """ + data = p4(connection, ["client", "-o", client])[0] + if "Update" not in data: + msg = f"Client {client!r} does not exists" + raise ClientNotFoundError(msg) + return Client(**data) # type: ignore[arg-type] + + +def get_change(connection: Connection, change: int) -> Change: + """Get changelist specification. + + Command: + `p4 change`_. + + Args: + connection: Perforce connection. + change: The changelist number. + + Raises: + ChangeUnknownError: ``change`` not found. + """ + try: + data = p4(connection, ["change", "-o", str(change)])[0] + except CommandExecutionError as error: + if error.data["data"].strip() == f"Change {change} unknown.": + raise ChangeUnknownError(change) from error + raise + return Change(**data) # type: ignore[arg-type] + + +def create_changelist(connection: Connection, description: str) -> ChangeInfo: + """Create and return a new changelist. + + Command: + `p4 change`_. + + Args: + connection: Perforce connection. + description: Changelist description. + """ + data = p4(connection, ["change", "-o"])[0] + data["Description"] = description + _ = extract_indexed_values(data, "Files") + p4(connection, ["change", "-i"], stdin=data) + + data = p4(connection, ["changes", "--me", "-m", "1", "-l"])[0] + return ChangeInfo(**data) # type: ignore[arg-type] + + +def changes( + connection: Connection, + user: str | None = None, + *, + status: ChangeStatus | None = None, + long_output: bool = False, +) -> Iterator[ChangeInfo]: + """Iter submitted and pending changelists. + + Command: + `p4 changes`_. + + Args: + connection: Perforce connection. + user: List only changes made from that user. + status: List only changes with the specified status. + long_output: List long output, with full text of each changelist description. + """ + command = ["changes"] + if user: + command += ["-u", user] + if status: + command += ["-s", str(status)] + if long_output: + command += ["-l"] + + for data in p4(connection, command): + yield ChangeInfo(**data) # type: ignore[arg-type] + + +def add( + connection: Connection, + filespecs: list[str], + *, + changelist: int | None = None, + preview: bool = False, +) -> tuple[list[ActionMessage], list[ActionInfo]]: + """Open ``filespecs`` in client workspace for **addition** to the depot. + + Command: + `p4 add`_. + + Args: + connection: Perforce connection. + filespecs: A list of `File specifications`_. + changelist: Open the files within the specified changelist. If not set, + the files are linked to the default changelist. + preview: Preview which files would be opened for add, without actually + changing any files or metadata. + + Returns: + `ActionInfo` and `ActionMessage` objects. `ActionInfo` are only included if + something unexpected happened during the operation. + """ + command = ["add"] + if changelist: + command += ["-c", str(changelist)] + if preview: + command += ["-n"] + command += filespecs + + messages: list[ActionMessage] = [] + infos: list[ActionInfo] = [] + for data in p4(connection, command): + if data["code"] == MarshalCode.INFO: + messages.append(ActionMessage.from_info_data(data)) + else: + infos.append(ActionInfo(**data)) # type: ignore[arg-type] + return messages, infos + + +def edit( + connection: Connection, + filespecs: list[str], + *, + changelist: int | None = None, + preview: bool = False, +) -> tuple[list[ActionMessage], list[ActionInfo]]: + """Open ``filespecs`` in client workspace for **edit**. + + Command: + `p4 edit`_. + + Args: + connection: Perforce connection. + filespecs: A list of `File specifications`_. + changelist: Open the files within the specified changelist. If not set, + the files are linked to the default changelist. + preview: Preview the result of the operation, without actually changing + any files or metadata. + + Returns: + `ActionInfo` and `ActionMessage` objects. `ActionInfo` are only included if + something unexpected happened during the operation. + """ + command = ["edit"] + if changelist: + command += ["-c", str(changelist)] + if preview: + command += ["-n"] + command += filespecs + + messages: list[ActionMessage] = [] + infos: list[ActionInfo] = [] + for data in p4(connection, command): + if data["code"] == MarshalCode.INFO: + messages.append(ActionMessage.from_info_data(data)) + else: + infos.append(ActionInfo(**data)) # type: ignore[arg-type] + return messages, infos + + +def delete( + connection: Connection, + filespecs: list[str], + *, + changelist: int | None = None, + preview: bool = False, +) -> tuple[list[ActionMessage], list[ActionInfo]]: + """Open ``filespecs`` in client workspace for **deletion** from the depo. + + Command: + `p4 delete`_. + + Args: + connection: Perforce connection. + filespecs: A list of `File specifications`_. + changelist: Open the files within the specified changelist. If not set, + the files are linked to the default changelist. + preview: Preview the result of the operation, without actually changing + any files or metadata. + + Returns: + `ActionInfo` and `ActionMessage` objects. `ActionInfo` are only included if + something unexpected happened during the operation. + """ + # TODO: investigate '-v' and '-k' options + command = ["delete"] + if changelist: + command += ["-c", str(changelist)] + if preview: + command += ["-n"] + command += filespecs + + messages: list[ActionMessage] = [] + infos: list[ActionInfo] = [] + for data in p4(connection, command): + if data["code"] == MarshalCode.INFO: + messages.append(ActionMessage.from_info_data(data)) + else: + infos.append(ActionInfo(**data)) # type: ignore[arg-type] + return messages, infos + + +def fstat( + connection: Connection, + filespecs: list[str], + *, + include_deleted: bool = False, +) -> Iterator[FStat]: + """List files information. + + Local files (not in depot and not opened for ``add``) are not included. + + Command: + `p4 fstat`_. + + Args: + connection: Perforce connection. + filespecs: A list of `File specifications`_. + include_deleted: Include files with a head action of ``delete`` or + ``move/delete``. + """ + # NOTE: not using: '-Ol': include 'fileSize' and 'digest' fields. + command = ["fstat"] + if not include_deleted: + command += ["-F", "^headAction=delete ^headAction=move/delete"] + command += filespecs + + local_paths = set() # TODO: not doing anything with local paths at the moment + for data in p4(connection, command, max_severity=MessageSeverity.WARNING): + if data["code"] == "error": + path, _, message = data["data"].rpartition(" - ") + path, message = path.strip(), message.strip() + if message == "no such file(s).": + local_paths.add(path) + else: + raise CommandExecutionError(data["data"], command=command, data=data) + else: + yield FStat(**data) # type: ignore[arg-type] + + +def get_revisions( + connection: Connection, + filespecs: list[str], + *, + long_output: bool = False, +) -> Iterator[list[Revision]]: + """List **all** revisions of files matching ``filespecs``. + + Command: + `p4 filelog`_. + + Args: + connection: Perforce Connection. + filespecs: A list of `File specifications`_. + long_output: List long output, with full text of each changelist description. + + Warning: + The lists are not intentionally sorted despite being *naturally* sorted by + descending revision (highest to lowset) due to how `p4 filelog`_ data are + processed. This behavior could change in the future, the order is not + guaranteed. + """ + # NOTE: Most fields ends with the rev number, like 'foo1', other indicate a + # relationship, like 'bar0,1' + regex = re.compile(r"([a-zA-Z]+)([0-9]+)(?:,([0-9]+))?") + + command = ["filelog"] + if long_output: + command += ["-l"] + command += filespecs + + for data in p4(connection, command): + revisions: dict[int, dict[str, str]] = {} + shared: dict[str, str] = {} + + for key, value in data.items(): + match = regex.match(key) + + if match: + prefix: str = match.group(1) + index = int(match.group(2)) + suffix = "" if match.group(3) is None else int(match.group(3)) + revisions.setdefault(index, {})[f"{prefix}{suffix}"] = value + else: + shared[key] = value + + yield [Revision(**rev, **shared) for rev in revisions.values()] # type: ignore[arg-type] + + +def sync(connection: Connection, filespecs: list[str]) -> list[Sync]: + """Update ``filespecs`` to the client workspace. + + Args: + connection: Perforce Connection. + filespecs: A list of `File specifications`_. + """ + # TODO: parallel as an option + command = ["sync", *filespecs] + output = p4(connection, command, max_severity=MessageSeverity.WARNING) + + result: list = [] + for data in output: + if data["code"] == MarshalCode.ERROR: + _, _, message = data["data"].rpartition(" - ") + message = message.strip() + if message == "file(s) up-to-date.": + log.debug(data["data"].strip()) + continue + raise CommandExecutionError(message, command=command, data=data) + + if data["code"] == MarshalCode.INFO: + log.info(data["data"].strip()) + continue + + # NOTE: The first item contain info about total file count and size. + if not result and "totalFileCount" in data: + total_files = int(data.pop("totalFileCount", 0)) + total_bytes = int(data.pop("totalFileSize", 0)) + log.info("Synced %s files (%s bytes)", total_files, total_bytes) + + result.append(Sync(**data)) # type: ignore[arg-type] + + return result + + +def login(connection: Connection, password: str) -> None: + """Login to Perforce Server. + + Raises: + AuthenticationError: Failed to login. + """ + command = ["p4", "-p", connection.port] + if connection.user: + command += ["-u", connection.user] + command += ["login"] + + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + _, stderr = process.communicate(password.encode()) + + if stderr: + raise AuthenticationError(stderr.decode().strip()) + + +def p4( + connection: Connection, + command: list[str], + stdin: PerforceDict | None = None, + max_severity: MessageSeverity = MessageSeverity.EMPTY, +) -> list[PerforceDict]: + """Run a ``p4`` command and return its output. + + This function uses `marshal` (using ``p4 -G``) to load stdout and dump stdin. + + Args: + connection: The connection to execute the command with. + command: A ``p4`` command to execute, with arguments. + stdin: Write a dict to the standard input file using `marshal.dump`. + max_severity: Raises an exception if the output error severity is above + that threshold. + + Returns: + The command output. + + Raises: + CommandExecutionError: An error occured during command execution. + ConnectionExpiredError: Connection to server expired, password is required. + You can use the `login` function. + """ + args = ["p4", "-G", "-p", connection.port] + if connection.user: + args += ["-u", connection.user] + if connection.client: + args += ["-c", connection.client] + args += command + + log.debug("Running: '%s'", " ".join(args)) + + result: list[PerforceDict] = [] + process = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE if stdin else None, + stderr=subprocess.PIPE, + ) + with process: + if stdin: + assert process.stdin is not None # noqa: S101 + marshal.dump(stdin, process.stdin, 0) # NOTE: perforce require version 0 + else: + assert process.stdout is not None # noqa: S101 + while True: + try: + out: dict[bytes, bytes | int] = marshal.load(process.stdout) # noqa: S302 + except EOFError: + break + + # NOTE: Some rare values, like user FullName, can be encoded + # differently, and decoding them with 'latin-1' give us a result + # that seem to match what P4V does. + data = { + key.decode(): val.decode("latin-1") + if isinstance(val, bytes) + else str(val) + for key, val in out.items() + } + + if ( + data.get("code") == MarshalCode.ERROR + and int(data["severity"]) > max_severity + ): + message = str(data["data"].strip()) + + if message == "Perforce password (P4PASSWD) invalid or unset.": + message = "Perforce connection expired, password is required" + raise ConnectionExpiredError(message) + + raise CommandExecutionError(message, command=args, data=data) + + result.append(data) + + _, stderr = process.communicate() + if stderr: + message = stderr.decode() + raise CommandExecutionError(message, command=args) + + return result diff --git a/src/pyforce/exceptions.py b/src/pyforce/exceptions.py new file mode 100644 index 0000000..97c6916 --- /dev/null +++ b/src/pyforce/exceptions.py @@ -0,0 +1,60 @@ +"""Pyforce Exceptions.""" + +from __future__ import annotations + +__all__ = [ + "PyforceError", + "ConnectionExpiredError", + "AuthenticationError", + "ChangeUnknownError", + "CommandExecutionError", + "UserNotFoundError", + "ClientNotFoundError", +] + + +class PyforceError(Exception): + """Base exception for the `pyforce` package.""" + + +class UserNotFoundError(PyforceError): + """Raised when a user is request but doesn't exists.""" + + +class ChangeUnknownError(PyforceError): + """Raised when a changelist is request but doesn't exists.""" + + +class ClientNotFoundError(PyforceError): + """Raised when a client workspace is request but doesn't exists.""" + + +class ConnectionExpiredError(PyforceError): + """Raised when the connection to the Helix Core Server has expired. + + You need to log back in. + """ + + +class AuthenticationError(PyforceError): + """Raised when login to the Helix Core Server failed.""" + + +class CommandExecutionError(PyforceError): + """Raised when an error occured during the execution of a `p4` command. + + Args: + message: Error message. + command: The executed command. + data: Optional marshalled output returned by the command. + """ + + def __init__( + self, + message: str, + command: list[str], + data: dict[str, str] | None = None, + ) -> None: + self.command = command + self.data = data or {} + super().__init__(message) diff --git a/src/pyforce/models.py b/src/pyforce/models.py new file mode 100644 index 0000000..357a77a --- /dev/null +++ b/src/pyforce/models.py @@ -0,0 +1,495 @@ +"""Pyforce models.""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass +from pathlib import Path # noqa: TCH003 +from typing import Any, Iterable, List, Literal, Mapping, NamedTuple, Union, cast + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from pyforce.utils import ( + MessageLevel, + PerforceDateTime, + PerforceDict, + PerforceTimestamp, + StrEnum, + extract_indexed_values, +) + +__all__ = [ + "UserType", + "AuthMethod", + "User", + "View", + "ClientType", + "ClientOptions", + "SubmitOptions", + "Client", + "ChangeStatus", + "ChangeType", + "Change", + "ChangeInfo", + "Action", + "ActionMessage", + "ActionInfo", + "Revision", + "Sync", + "OtherOpen", + "HeadInfo", + "FStat", +] + + +class PyforceModel(BaseModel): + model_config = ConfigDict(extra="allow") + + def __repr_args__(self) -> Iterable[tuple[str | None, Any]]: + """Filter out extras.""" + extra = self.__pydantic_extra__ + if not extra: + return super().__repr_args__() + return ( + (key, val) for (key, val) in super().__repr_args__() if key not in extra + ) + + +class UserType(StrEnum): + """Types of user enum.""" + + STANDARD = "standard" + OPERATOR = "operator" + SERVICE = "service" + + +class AuthMethod(StrEnum): + """User authentication enum.""" + + PERFORCE = "perforce" + LDAP = "ldap" + + +class User(PyforceModel): + """A Perforce user specification.""" + + access: PerforceDateTime = Field(alias="Access", repr=False) + """The date and time this user last ran a Helix Server command.""" + + auth_method: AuthMethod = Field(alias="AuthMethod", repr=False) + email: str = Field(alias="Email") + full_name: str = Field(alias="FullName") + type: UserType = Field(alias="Type") + + update: PerforceDateTime = Field(alias="Update", repr=False) + """The date and time this user was last updated.""" + + name: str = Field(alias="User") + + +class SubmitOptions(StrEnum): + """Options to govern the default behavior of ``p4 submit``.""" + + SUBMIT_UNCHANGED = "submitunchanged" + SUBMIT_UNCHANGED_AND_REOPEN = "submitunchanged+reopen" + REVERT_UNCHANGED = "revertunchanged" + REVERT_UNCHANGED_AND_REOPEN = "revertunchanged+reopen" + LEAVE_UNCHANGED = "leaveunchanged" + LEAVE_UNCHANGED_AND_REOPEN = "leaveunchanged+reopen" + + +class ClientType(StrEnum): + """Types of client workspace.""" + + STANDARD = "writeable" + OPERATOR = "readonly" + SERVICE = "partitioned" + + +class View(NamedTuple): + """A perforce `View specification`_.""" + + left: str + right: str + + @staticmethod + def from_string(string: str) -> View: + """New instance from a view string. + + Example: + >>> View.from_string("//depot/foo/... //ws/bar/...") + View(left='//depot/foo/...', right='//ws/bar/...') + """ + return View(*shlex.split(string)) + + +@dataclass +class ClientOptions: + """A set of switches that control particular `Client options`_. + + .. _Client options: + https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_client.html#Options2 + """ + + allwrite: bool + clobber: bool + compress: bool + locked: bool + modtime: bool + rmdir: bool + + @classmethod + def from_string(cls, string: str) -> ClientOptions: + """Instanciate class from an option line returned by p4.""" + data = set(string.split()) + return cls( + allwrite="allwrite" in data, + clobber="clobber" in data, + compress="compress" in data, + locked="locked" in data, + modtime="modtime" in data, + rmdir="rmdir" in data, + ) + + def __str__(self) -> str: + options = [ + "allwrite" if self.allwrite else "noallwrite", + "clobber" if self.clobber else "noclobber", + "compress" if self.compress else "nocompress", + "locked" if self.locked else "nolocked", + "modtime" if self.modtime else "nomodtime", + "rmdir" if self.rmdir else "normdir", + ] + return " ".join(options) + + +class Client(PyforceModel): + """A Perforce client workspace specification.""" + + access: PerforceDateTime = Field(alias="Access", repr=False) + """The date and time that the workspace was last used in any way.""" + + name: str = Field(alias="Client") + description: str = Field(alias="Description", repr=False) + + host: str = Field(alias="Host") + """The name of the workstation on which this workspace resides.""" + + options: ClientOptions = Field(alias="Options", repr=False) + + owner: str = Field(alias="Owner") + """The name of the user who owns the workspace.""" + + root: Path = Field(alias="Root") + """Workspace root directory on the local host + + All the file in `views` are relative to this directory. + """ + + stream: Union[str, None] = Field(alias="Stream", default=None) + submit_options: SubmitOptions = Field(alias="SubmitOptions", repr=False) + type: ClientType = Field(alias="Type") + + update: PerforceDateTime = Field(alias="Update", repr=False) + """The date the workspace specification was last modified.""" + + views: List[View] + """Specifies the mappings between files in the depot and files in the workspace.""" + + @field_validator("options", mode="before") + @classmethod + def _validate_options(cls, v: str) -> ClientOptions: + return ClientOptions.from_string(v) + + @model_validator(mode="before") + @classmethod + def _prepare_views(cls, data: dict[str, object]) -> dict[str, object]: + data["views"] = [ + View.from_string(cast(str, v)) + for v in extract_indexed_values(data, prefix="View") + ] + return data + + +class ChangeStatus(StrEnum): + """Types of changelist status.""" + + PENDING = "pending" + SHELVED = "shelved" + SUBMITTED = "submitted" + # TODO: NEW = "new" ? + + +class ChangeType(StrEnum): + """Types of changelist.""" + + RESTRICTED = "restricted" + PUBLIC = "public" + + +class Change(PyforceModel): + """A Perforce changelist specification. + + Command: + `p4 change`_ + """ + + change: int = Field(alias="Change") + client: str = Field(alias="Client") + + date: PerforceDateTime = Field(alias="Date") + """Date the changelist was last modified.""" + + description: str = Field(alias="Description", repr=False) + status: ChangeStatus = Field(alias="Status") + type: ChangeType = Field(alias="Type") + + user: str = Field(alias="User") + """Name of the change owner.""" + + files: List[str] = Field(repr=False) + """The list of files being submitted in this changelist.""" + + shelve_access: Union[PerforceDateTime, None] = Field( + alias="shelveAccess", + default=None, + ) + shelve_update: Union[PerforceDateTime, None] = Field( + alias="shelveUpdate", + default=None, + ) + + @model_validator(mode="before") + @classmethod + def _prepare_files(cls, data: dict[str, object]) -> dict[str, object]: + data["files"] = extract_indexed_values(data, prefix="Files") + return data + + +class ChangeInfo(PyforceModel): + """A Perforce changelist. + + Compared to a `Change`, this model does not contain the files in the changelist. + + Command: + `p4 changes`_ + """ + + change: int = Field(alias="change") + client: str = Field(alias="client") + + date: PerforceTimestamp = Field(alias="time") + """Date the changelist was last modified.""" + + description: str = Field(alias="desc", repr=False) + status: ChangeStatus = Field(alias="status") + type: ChangeType = Field(alias="changeType") + + user: str = Field(alias="user") + """Name of the change owner.""" + + +class Action(StrEnum): + """A file action.""" + + ADD = "add" + EDIT = "edit" + DELETE = "delete" + BRANCH = "branch" + MOVE_ADD = "move/add" + MOVE_DELETE = "move/delete" + INTEGRATE = "integrate" + IMPORT = "import" + PURGE = "purge" + ARCHIVE = "archive" + + +@dataclass(frozen=True) +class ActionMessage: + """Information on a file during an action operation. + + Actions can be, for example, ``add``, ``edit`` or ``remove``. + + Notable messages: + - "can't add (already opened for edit)" + - "can't add existing file" + - "empty, assuming text." + - "also opened by user@client" + """ + + path: str + message: str + level: MessageLevel + + @classmethod + def from_info_data(cls, data: PerforceDict) -> ActionMessage: + """Create instance from an 'info' dict of an action command.""" + path, _, message = data["data"].rpartition(" - ") + level = MessageLevel(int(data["level"])) + return cls( + path=path.strip(), + message=message.strip(), + level=level, + ) + + +class ActionInfo(PyforceModel): + """The result of an action operation. + + Actions can be, for example, ``add``, ``edit`` or ``remove``. + """ + + action: str + client_file: str = Field(alias="clientFile") + depot_file: str = Field(alias="depotFile") + file_type: str = Field(alias="type") # TODO: create object type ? 'binary+F' + work_rev: int = Field(alias="workRev") + """Open revision.""" + + +class Revision(PyforceModel): + """A file revision information.""" + + action: Action + """The operation the file was open for.""" + + change: int + """The number of the submitting changelist.""" + + client: str + depot_file: str = Field(alias="depotFile") + description: str = Field(alias="desc", repr=False) + + digest: Union[str, None] = Field(default=None, repr=False) + """MD5 digest of the file. ``None`` if ``action`` is `Action.DELETE`.""" + + # TODO: None when action is 'deleted' + file_size: Union[int, None] = Field(default=None) + """File length in bytes. ``None`` if ``action`` is `Action.DELETE`.""" + + revision: int = Field(alias="rev") + time: PerforceTimestamp + + # TODO: enum ? https://www.perforce.com/manuals/cmdref/Content/CmdRef/file.types.html + file_type: str = Field(alias="type") + + user: str + """The name of the user who submitted the revision.""" + + +class Sync(PyforceModel): + """The result of a file sync operation.""" + + action: str + client_file: str = Field(alias="clientFile") # TODO: Could be Path + depot_file: str = Field(alias="depotFile") + revision: int = Field(alias="rev") # TODO: can be None ? + + # TODO: can be None if action is 'delete' or 'move/delete' + file_size: int = Field(alias="fileSize") + + +@dataclass(frozen=True) +class OtherOpen: + """Other Open information.""" + + action: Action + change: Union[int, Literal["default"]] + user: str + client: str + + +class HeadInfo(PyforceModel): + """Head revision information.""" + + action: Action = Field(alias="headAction") + change: int = Field(alias="headChange") + revision: int = Field(alias="headRev") + """Revision number. + + If you used a `Revision specifier`_ in your query, this field is set to the + specified value. Otherwise, it's the head revision. + + .. _Revision specifier: + https://www.perforce.com/manuals/cmdref/Content/CmdRef/filespecs.html#Using_revision_specifiers + """ + + file_type: str = Field(alias="headType") + time: PerforceTimestamp = Field(alias="headTime") + """Revision **changelist** time.""" + + mod_time: PerforceTimestamp = Field(alias="headModTime") + """Revision modification time. + + The time that the file was last modified on the client before submit. + """ + + +class FStat(PyforceModel): + """A file information.""" + + client_file: str = Field(alias="clientFile") + depot_file: str = Field(alias="depotFile") + head: Union[HeadInfo, None] = Field(alias="head", default=None) + + have_rev: Union[int, None] = Field(alias="haveRev", default=None) + """Revision last synced to workspace, if on workspace.""" + + is_mapped: bool = Field(alias="isMapped", default=False) + """Is the file is mapped to client workspace.""" + + others_open: Union[List[OtherOpen], None] = Field(default=None) + + @field_validator("is_mapped", mode="before") + @classmethod + def _validate_is_mapped(cls, v: str | None) -> bool: + return v == "" + + @model_validator(mode="before") + @classmethod + def _prepare_head(cls, data: dict[str, object]) -> dict[str, object]: + if "headRev" not in data: + return data + + head_info = { + "headAction": data.pop("headAction"), + "headChange": data.pop("headChange"), + "headRev": data.pop("headRev"), + "headType": data.pop("headType"), + "headTime": data.pop("headTime"), + "headModTime": data.pop("headModTime"), + } + + charset = data.pop("headCharset", None) + if charset: + head_info["headCharset"] = charset + + data["head"] = head_info + return data + + @model_validator(mode="before") + @classmethod + def _prepare_others_open(cls, data: dict[str, object]) -> Mapping[str, object]: + total = cast("str | None", data.pop("otherOpen", None)) + if total is None: + return data + + result: list[OtherOpen] = [] + for index in range(int(total)): + user, _, client = cast(str, data.pop(f"otherOpen{index}")).partition("@") + other = OtherOpen( + action=Action(cast(str, data.pop(f"otherAction{index}"))), + change=int(cast(str, data.pop(f"otherChange{index}"))), + user=user, + client=client, + ) + result.append(other) + + data["others_open"] = result + return data diff --git a/src/pyforce/utils.py b/src/pyforce/utils.py new file mode 100644 index 0000000..6419ea2 --- /dev/null +++ b/src/pyforce/utils.py @@ -0,0 +1,197 @@ +"""Pyforce utils.""" + +from __future__ import annotations + +import datetime +import itertools +import logging +import sys +from enum import Enum, IntEnum +from typing import Dict, Final, NamedTuple + +from pydantic import BeforeValidator, PlainSerializer +from typing_extensions import Annotated, TypeVar + +if sys.version_info < (3, 11): + + class StrEnum(str, Enum): + """Enum where members are also (and must be) stings.""" +else: + from enum import StrEnum + + +__all__ = [ + "PerforceDict", + "Connection", + "MessageSeverity", + "MarshalCode", +] + +log: Final = logging.getLogger("pyforce") + +PerforceDict = Dict[str, str] + +R = TypeVar("R") + + +class Connection(NamedTuple): + """Perforce connection informations.""" + + port: str + """Perforce host and port (``P4PORT``).""" + + user: str | None = None + """Helix server username (``P4USER``).""" + + client: str | None = None + """Client workspace name (``P4CLIENT``).""" + + +class MarshalCode(StrEnum): + """Values of the ``code`` field from a marshaled P4 response. + + The output dictionary from ``p4 -G`` must have a ``code`` field. + """ + + STAT = "stat" + """Means 'status' and is the default status.""" + + ERROR = "error" + """An error has occured. + + The full error message is contained in the 'data' field. + """ + + INFO = "info" + """There was some feedback from the command. + + The message is contained in the 'data' field. + """ + + +class MessageSeverity(IntEnum): + """Perforce message severity levels.""" + + EMPTY = 0 + """No Error.""" + + INFO = 1 + """Informational message, something good happened.""" + + WARNING = 2 + """Warning message, something not good happened.""" + + FAILED = 3 + """Command failed, user did something wrong.""" + + FATAL = 4 + """System broken, severe error, cannot continue.""" + + +class MessageLevel(IntEnum): + """Perforce generic 'level' codes, as described in `P4.Message`_. + + .. _P4.Message: + https://www.perforce.com/manuals/p4python/Content/P4Python/python.p4_message.html + """ + + NONE = 0 + """Miscellaneous.""" + + USAGE = 0x01 + """Request is not consistent with dox.""" + + UNKNOWN = 0x02 + """Using unknown entity.""" + + CONTEXT = 0x03 + """Using entity in the wrong context.""" + + ILLEGAL = 0x04 + """You do not have permission to perform this action.""" + + NOTYET = 0x05 + """An issue needs to be fixed before you can perform this action.""" + + PROTECT = 0x06 + """Protections prevented operation.""" + + EMPTY = 0x11 + """Action returned empty results.""" + + FAULT = 0x21 + """Inexplicable program fault.""" + + CLIENT = 0x22 + """Client side program errors.""" + + ADMIN = 0x23 + """Server administrative action required.""" + + CONFIG = 0x24 + """Client configuration is inadequate.""" + + UPGRADE = 0x25 + """Client or server too old to interact.""" + + COMM = 0x26 + """Communications error.""" + + TOOBIG = 0x27 + """Too big to handle.""" + + +PERFORCE_DATE_FORMAT = "%Y/%m/%d %H:%M:%S" + + +def perforce_date_to_datetime(string: str) -> datetime.datetime: + utc = datetime.timezone.utc + return datetime.datetime.strptime(string, PERFORCE_DATE_FORMAT).replace(tzinfo=utc) + + +def perforce_timestamp_to_datetime(time: str) -> datetime.datetime: + return datetime.datetime.fromtimestamp(int(time), tz=datetime.timezone.utc) + + +def perforce_datetime_to_timestamp(date: datetime.datetime) -> str: + return str(round(date.timestamp())) + + +def datetime_to_perforce_date(date: datetime.datetime) -> str: + return date.strftime(PERFORCE_DATE_FORMAT) + + +PerforceDateTime = Annotated[ + datetime.datetime, + BeforeValidator(perforce_date_to_datetime), + PlainSerializer(datetime_to_perforce_date), +] + + +PerforceTimestamp = Annotated[ + datetime.datetime, + BeforeValidator(perforce_timestamp_to_datetime), + PlainSerializer(perforce_datetime_to_timestamp), +] + + +def extract_indexed_values(data: dict[str, R], prefix: str) -> list[R]: + """Pop indexed keys, in `data` to list of value. + + Args: + data: dict to pop process. + prefix: Indexed key prefix. For example, the keys in + `{'Files0': "//depot/foo", 'Files1': "//depot/bar"}` have the prefix + `Files`. + """ + result: list[R] = [] + counter = itertools.count() + + while True: + index = next(counter) + value: R | None = data.pop(f"{prefix}{index}", None) + if value is None: + break + result.append(value) + + return result diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4d0d3db --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,214 @@ +"""Configuration and fixtures.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import time +import uuid +from pathlib import Path +from typing import Iterator, Protocol + +import pytest + +import pyforce + + +def create_user( + connection: pyforce.Connection, + name: str, + full_name: str | None = None, + email: str | None = None, +) -> pyforce.User: + """Create a new user on perforce server and return it.""" + data = pyforce.p4(connection, ["user", "-o", name])[0] + + if full_name is not None: + data["FullName"] = full_name + if email is not None: + data["Email"] = email + + pyforce.p4(connection, ["user", "-i", "-f"], stdin=data) + return pyforce.get_user(connection, name) + + +class UserFactory(Protocol): + """Create and return a new user.""" + + def __call__(self, name: str) -> pyforce.User: # noqa: D102 + ... + + +@pytest.fixture() +def user_factory(server: str) -> Iterator[UserFactory]: + """Factory fixture that create and return a new user.""" + connection = pyforce.Connection(port=server) + created: list[pyforce.User] = [] + + def factory(name: str) -> pyforce.User: + user = create_user(connection, name) + created.append(user) + return user + + yield factory + + for user in created: + pyforce.p4(connection, ["user", "-d", "-f", user.name]) + + +def create_client( + connection: pyforce.Connection, + name: str, + stream: str | None = None, + root: Path | None = None, +) -> pyforce.Client: + """Create a new `Client` on perforce server and return it.""" + command = ["client", "-o"] + if stream is not None: + command += ["-S", stream] + command += [name] + + data = pyforce.p4(connection, command)[0] + + if root is not None: + data["Root"] = str(root.resolve()) + + pyforce.p4(connection, ["client", "-i"], stdin=data) + return pyforce.get_client(connection, name) + + +class ClientFactory(Protocol): + """Create and return a new client.""" + + def __call__(self, name: str, stream: str | None = None) -> pyforce.Client: # noqa: D102 + ... + + +@pytest.fixture() +def client_factory(server: str) -> Iterator[ClientFactory]: + """Factory fixture that create and return a new client.""" + connection = pyforce.Connection(port=server) + created: list[tuple[pyforce.Client, Path]] = [] + + def factory(name: str, stream: str | None = None) -> pyforce.Client: + root = Path(tempfile.mkdtemp(prefix="pyforce-client-")) + client = create_client(connection, name, stream=stream, root=root) + created.append((client, root)) + return client + + yield factory + + for client, root in created: + pyforce.p4(connection, ["client", "-d", "-f", client.name]) + if root.exists(): + shutil.rmtree(root) + + +@pytest.fixture() +def client(server: str) -> Iterator[pyforce.Client]: + """Create a client on a mainline stream for the duration of the test. + + This fixture create (and tear-down) a stream depot, a mainline stream and + a client on that stream. + """ + connection = pyforce.Connection(port=server) + + depot = f"depot-{uuid.uuid4().hex}" + data = pyforce.p4(connection, ["depot", "-o", "-t", "stream", depot])[0] + pyforce.p4(connection, ["depot", "-i"], stdin=data) + + stream = f"//{depot}/stream-{uuid.uuid4().hex}" + data = pyforce.p4(connection, ["stream", "-o", "-t", "mainline", stream])[0] + pyforce.p4(connection, ["stream", "-i"], stdin=data) + + client = create_client( + connection, + f"client-{uuid.uuid4().hex}", + stream=stream, + root=Path(tempfile.mkdtemp(prefix="pyforce-client-")), + ) + + yield client + + pyforce.p4(connection, ["client", "-d", "-f", client.name]) + pyforce.p4(connection, ["stream", "-d", "--obliterate", "-y", stream]) + pyforce.p4(connection, ["obliterate", "-y", f"//{depot}/..."]) + pyforce.p4(connection, ["depot", "-d", depot]) + shutil.rmtree(client.root) + + +class FileFactory(Protocol): + """Create and return a new file.""" + + def __call__(self, root: Path, prefix: str = "file", content: str = "") -> Path: # noqa: D102 + ... + + +@pytest.fixture() +def file_factory() -> FileFactory: + """Factory fixture that create and return a new file.""" + + def factory(root: Path, prefix: str = "file", content: str = "") -> Path: + path = root / f"{prefix}-{uuid.uuid4().hex}" + with path.open("w") as fp: + fp.write(content) + return path + + return factory + + +@pytest.fixture(autouse=True, scope="session") +def server() -> Iterator[str]: + """Create a temporary perforce server for the duration of the test session. + + Yield: + The server port ('localhost:1666'). + """ + # Backup p4 variables set as env variables, and clear them. + # This should override all variables that could cause issue with the tests + # We override 'P4ENVIRON' and 'P4CONFIG' because both files, if set, could + # overrides environment variables. + # Predecence for variables: + # https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_set.html + backup_env: dict[str, str] = {} + for key in ("P4CLIENT", "P4PORT", "P4USER", "P4CONFIG", "P4ENVIRO"): + value = os.environ.pop(key, None) + if value is not None: + backup_env[key] = value + + # Run server + root = Path(tempfile.mkdtemp(prefix="pyforce-server-")) + port = "localhost:1666" # default port on localhost + user = "remote" # same as p4d default + timeout = 5 # in seconds + + process = subprocess.Popen(["p4d", "-r", str(root), "-p", port, "-u", user]) + start_time = time.time() + + while True: + if time.time() > start_time + timeout: + msg = f"Server initialization timed out after {timeout} seconds" + raise RuntimeError(msg) + + try: + subprocess.check_call(["p4", "-p", port, "-u", user, "info"]) + except subprocess.CalledProcessError: + continue + else: + break + + yield port + + # Kill server + process.kill() + process.communicate() + + # Restore environment + for key, value in backup_env.items(): + os.environ[key] = value + + # Delete server root + if root.exists(): + shutil.rmtree(root) diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..e2c4ba6 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,411 @@ +"""Test suite for the commands module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +import pyforce + +if TYPE_CHECKING: + from conftest import ClientFactory, FileFactory, UserFactory + + +def test_get_user_returns_expected_user(server: str, user_factory: UserFactory) -> None: + """It returns the expected `User` instance.""" + user_factory("foo") + connection = pyforce.Connection(port=server) + user = pyforce.get_user(connection, "foo") + assert isinstance(user, pyforce.User) + assert user.name == "foo" + + +def test_get_user_raise_user_not_found(server: str) -> None: + """It raises an error when user does not exists.""" + connection = pyforce.Connection(port=server) + with pytest.raises(pyforce.UserNotFoundError): + pyforce.get_user(connection, "foo") + + +def test_get_client_returns_expected_client( + server: str, + client_factory: ClientFactory, +) -> None: + """It returns the expected `Client` instance.""" + client_factory("foo") + connection = pyforce.Connection(port=server) + user = pyforce.get_client(connection, "foo") + assert isinstance(user, pyforce.Client) + assert user.name == "foo" + + +def test_get_client_raise_client_not_found(server: str) -> None: + """It raises an error when client does not exists.""" + connection = pyforce.Connection(port=server) + with pytest.raises(pyforce.ClientNotFoundError): + pyforce.get_client(connection, "foo") + + +def test_get_change_return_expected_changelist( + server: str, + client: pyforce.Client, +) -> None: + """It returns a Change instance of expected change.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + change_info = pyforce.create_changelist(connection, "Foo") + + change = pyforce.get_change(connection, change_info.change) + assert isinstance(change, pyforce.Change) + assert change.change == change_info.change + + +def test_get_change_raise_change_not_found( + server: str, + client: pyforce.Client, +) -> None: + """It raises an error when change does not exists.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + with pytest.raises(pyforce.ChangeUnknownError): + pyforce.get_change(connection, 123) + + +def test_create_changelist_return_change_info( + server: str, + client: pyforce.Client, +) -> None: + """It create a new changelist and return it.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + info = pyforce.create_changelist(connection, "Foo Bar") + assert isinstance(info, pyforce.ChangeInfo) + assert info.description.strip() == "Foo Bar" + + +def test_create_changelist_return_correct_change( + server: str, + client: pyforce.Client, +) -> None: + """When multiple changelists already exists, it return the correct one.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + pyforce.create_changelist(connection, "Foo") + info = pyforce.create_changelist(connection, "Bar") + assert info.description.strip() == "Bar" + + +def test_create_changelist_return_existing_changelist( + server: str, + client: pyforce.Client, +) -> None: + """The changelist is actually created on the server.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + info = pyforce.create_changelist(connection, "Foo") + change = pyforce.get_change(connection, info.change) + assert change.change == info.change + assert change.description == info.description + + +def test_create_changelist_contain_no_files( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It does not includes files from default changelist.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + file = file_factory(root=client.root, content="foo") + pyforce.add(connection, [str(file)]) + info = pyforce.create_changelist(connection, "Foo") + change = pyforce.get_change(connection, info.change) + assert change.files == [] + + +# TODO: test_changes + + +def test_add_return_added_file( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It returns the added files.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + file = file_factory(root=client.root, content="foo") + messages, infos = pyforce.add(connection, [str(file)]) + assert not messages + assert len(infos) == 1 + assert isinstance(infos[0], pyforce.ActionInfo) + assert infos[0].client_file == str(file) + + +def test_add_empty_file_return_addinfo( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It returns info object with correct information.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + file = file_factory(root=client.root, content="") + messages, _ = pyforce.add(connection, [str(file)]) + assert len(messages) == 1 + assert isinstance(messages[0], pyforce.ActionMessage) + assert messages[0].message == "empty, assuming text." + + +def test_add_to_changelist( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It open file for add in specified changelist.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + + change_info = pyforce.create_changelist(connection, "Foo") + + file = file_factory(root=client.root, content="foobar") + _, infos = pyforce.add(connection, [str(file)], changelist=change_info.change) + + change = pyforce.get_change(connection, change_info.change) + assert infos[0].depot_file == change.files[0] + + +def test_add_preview_does_not_add_file_to_changelist( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """Running add in preview mode does not add file to changelist.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + change_info = pyforce.create_changelist(connection, "Foo") + + pyforce.add( + connection, + [str(file_factory(root=client.root, content="foobar"))], + changelist=change_info.change, + preview=True, + ) + + change = pyforce.get_change(connection, change_info.change) + assert change.files == [] + + +def test_edit_return_edited_files( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It returns the edited files.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + file = file_factory(root=client.root, content="foo") + + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + messages, infos = pyforce.edit(connection, [str(file)]) + + assert not messages + assert len(infos) == 1 + assert isinstance(infos[0], pyforce.ActionInfo) + assert infos[0].client_file == str(file) + + +def test_edit_add_file_to_changelist( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It open file for edit in specified changelist.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + + file = file_factory(root=client.root, content="foo") + change_info = pyforce.create_changelist(connection, "Foo") + + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + _, infos = pyforce.edit(connection, [str(file)], changelist=change_info.change) + + change = pyforce.get_change(connection, change_info.change) + assert infos[0].depot_file == change.files[0] + + +def test_edit_preview_does_not_add_file_to_changelist( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """Running edit in preview mode does not open file to changelist.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + + change_info = pyforce.create_changelist(connection, "Foo") + + file = file_factory(root=client.root, content="foo") + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + + pyforce.edit(connection, [str(file)], changelist=change_info.change, preview=True) + + change = pyforce.get_change(connection, change_info.change) + assert change.files == [] + + +def test_delete_return_deleted_files( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It returns the edited files.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + file = file_factory(root=client.root, content="foo") + + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + messages, infos = pyforce.delete(connection, [str(file)]) + + assert not messages + assert len(infos) == 1 + assert isinstance(infos[0], pyforce.ActionInfo) + assert infos[0].client_file == str(file) + + +def test_delete_add_file_to_changelist( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It open file for deletion to specified changelist.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + + file = file_factory(root=client.root, content="foo") + change_info = pyforce.create_changelist(connection, "Foo") + + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + _, infos = pyforce.delete(connection, [str(file)], changelist=change_info.change) + + change = pyforce.get_change(connection, change_info.change) + assert infos[0].depot_file == change.files[0] + + +def test_delete_preview_does_not_add_file_to_changelist( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """Running delete in preview mode does not open file to changelist.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + + change_info = pyforce.create_changelist(connection, "Foo") + + file = file_factory(root=client.root, content="foo") + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + + pyforce.delete(connection, [str(file)], changelist=change_info.change, preview=True) + + change = pyforce.get_change(connection, change_info.change) + assert change.files == [] + + +def test_sync_return_synced_files( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """It sync files and return them.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + file = file_factory(root=client.root, content="foo") + + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + pyforce.p4(connection, ["sync", "-f", f"{file}#0"]) + + synced_files = pyforce.sync(connection, [str(file)]) + + assert len(synced_files) == 1 + assert isinstance(synced_files[0], pyforce.Sync) + assert synced_files[0].client_file == str(file) + assert synced_files[0].revision == 1 + + +def test_sync_skip_up_to_date_file( + server: str, + file_factory: FileFactory, + client: pyforce.Client, +) -> None: + """Is skip up to date files without raising error.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + file = file_factory(root=client.root, content="foo") + + pyforce.add(connection, [str(file)]) + pyforce.p4(connection, ["submit", "-d", "Added Foo", str(file)]) + + synced_files = pyforce.sync(connection, [str(file)]) + assert len(synced_files) == 0 + + +def test_fstat_return_fstat_instance( + server: str, + client: pyforce.Client, + file_factory: FileFactory, +) -> None: + """It return expected object type.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + path = file_factory(root=client.root, content="foo") + + pyforce.add(connection, [str(path)]) + pyforce.p4(connection, ["submit", "-d", "Foo", str(path)]) + + fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=False)) + assert isinstance(fstats[0], pyforce.FStat) + + +def test_fstat_return_remote_file( + server: str, + client: pyforce.Client, + file_factory: FileFactory, +) -> None: + """It list remote file.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + path = file_factory(root=client.root, content="foo") + + pyforce.add(connection, [str(path)]) + pyforce.p4(connection, ["submit", "-d", "Foo", str(path)]) + + fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=False)) + assert len(fstats) == 1 + assert fstats[0].client_file == str(path) + assert fstats[0].head + assert fstats[0].head.revision # NOTE: check is in depot + assert fstats[0].have_rev # NOTE: check is client + + +def test_fstat_return_deleted_file( + server: str, + client: pyforce.Client, + file_factory: FileFactory, +) -> None: + """It list deleted file if include_deleted is True.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + path = file_factory(root=client.root, content="foo") + + pyforce.add(connection, [str(path)]) + pyforce.p4(connection, ["submit", "-d", "Adding foo", str(path)]) + pyforce.p4(connection, ["delete", str(path)]) + pyforce.p4(connection, ["submit", "-d", "Deleting Foo", str(path)]) + + fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=True)) + assert len(fstats) == 1 + assert fstats[0].client_file == str(path) + + +def test_fstat_ignore_local_file( + server: str, + client: pyforce.Client, + file_factory: FileFactory, +) -> None: + """It ignore local files.""" + connection = pyforce.Connection(port=server, user=client.owner, client=client.name) + path = file_factory(root=client.root, content="foo") + + fstats = list(pyforce.fstat(connection, [str(path)], include_deleted=False)) + assert len(fstats) == 0 + + +# TODO: test_get_revisions