Skip to content

Instantly share code, notes, and snippets.

@Leandros
Last active January 9, 2022 00:09
Show Gist options
  • Save Leandros/bf099f9e880dcbbe800a84b090a2fe58 to your computer and use it in GitHub Desktop.
Save Leandros/bf099f9e880dcbbe800a84b090a2fe58 to your computer and use it in GitHub Desktop.
p4.py - p4 offline caching
#!/usr/bin/env python3
# p4.py - p4 offline caching
#
# Copyright (c) 2017 Arvid Gerstmann
#
# 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.
#
#
# DESCRIPTION:
# p4.py will act as a wrapper between you and p4. When you have a working
# network connection, p4.py will forward all passed arguments unchanged to p4.
# In the case you have no network, p4.py will cache all 'add', 'edit',
# or 'delete' commands.
# Once you invoke p4.py with a working connection again, the cached commands
# will be replayed and added to p4. This process can be done manually by
# invoking 'p4.py replay'.
#
#
# INSTALLATION:
# - Copy the 'p4.py' file into your path.
# - Make sure the 'p4' executable is in your path
# - Rename 'p4' to '_p4', symlink 'p4.py' to 'p4' (optional)
#
#
# USAGE:
# p4.py - offline caching
#
# Usage: p4.py [COMMAND] FILE...
#
# Cached commands:
# add Open files for adding to the depot
# edit Open existing files for editing
# delete Open existing files for removal from the depot
# revert Revert open files and restore originals to workspace
#
# Extended commands:
# replay Replay all offline cached commands
#
#
# REVISION HISTORY:
# 1.0.1 (2018-01-12)
# Use google.com as network indicator
# 1.0.0 (2018-01-12)
# initial release
#
import os
import sys
import subprocess
import platform
import socket
import pickle
# =============================================================================
# Helper
# =============================================================================
def has_internet():
try:
host = socket.gethostbyname("google.com")
s = socket.create_connection((host, 80), 2)
return True
except:
pass
return False
def is_posix():
return not platform.system() == "Windows"
def find_root(anchor=".p4ignore"):
cur_dir = os.getcwd()
while True:
file_list = os.listdir(cur_dir)
parent_dir = os.path.dirname(cur_dir)
if anchor in file_list:
return cur_dir
else:
if cur_dir == parent_dir: break
else: cur_dir = parent_dir
return os.getcwd()
def to_relative_path(path):
if type(path).__name__ == "CachedFile":
return os.path.relpath(path.path)
return os.path.relpath(path)
def set_readwrite(path):
if is_posix():
p = subprocess.run(["chmod", "+w", path])
else:
p = subprocess.run(["attrib", "-r", path])
if p.returncode != 0:
raise IOError("Could not set file read/write")
def set_readonly(path):
if is_posix():
p = subprocess.run(["chmod", "-w", path])
else:
p = subprocess.run(["attrib", "+r", path])
if p.returncode != 0:
raise IOError("Could not set file read-only")
# =============================================================================
# P4 forwarding
# =============================================================================
def forward_to_p4(argc, argv):
args = []
args.extend(["p4"])
args.extend(argv[1:])
p = subprocess.run(args, stdout = subprocess.PIPE, stderr = subprocess.STDOUT)
sys.stdout.write(p.stdout.decode("utf-8"))
sys.stdout.flush()
return p.returncode
# =============================================================================
# Offline caching
# =============================================================================
class CachedFile:
def __init__(self, path):
with open(path, "rb") as f:
self.content = f.read()
self.path = os.path.abspath(path)
set_readwrite(self.path)
def __eq__(self, rhs):
if type(rhs).__name__ == "CachedFile":
return self.path == rhs.path
return self.path == rhs
class Cache:
def __init__(self):
self.added_files = []
self.opened_files = []
self.deleted_files = []
@staticmethod
def load_from_file(path):
with open(path, "rb") as f:
return pickle.load(f)
def save_to_file(self, path):
with open(path, "wb") as f:
pickle.dump(self, f)
def is_open(self, path):
return path in self.opened_files
def open_file(self, path):
path = os.path.abspath(path)
if not path in self.opened_files:
self.opened_files.append(CachedFile(path))
print("{} - opened for edit".format(to_relative_path(path)))
elif path in self.opened_files:
print("{} - currently opened for edit".format(to_relative_path(path)))
elif path in self.added_files:
print("{} - currently opened for add".format(to_relative_path(path)))
return 0
def revert_file(self, path):
path = os.path.abspath(path)
if path in self.opened_files:
os.remove(path)
with open(path, "wb") as f:
idx = self.opened_files.index(path)
f.write(self.opened_files[idx].content)
self.opened_files.remove(path)
set_readonly(path)
print("{} - was edit, reverted".format(to_relative_path(path)))
elif path in self.added_files:
# Do not actually delete the file, as a safety-measurement.
self.added_files.remove(path)
print("{} - was add, abandoned".format(to_relative_path(path)))
elif path in self.deleted_files:
with open(path, "wb") as f:
idx = self.deleted_files.index(path)
f.write(self.deleted_files[idx].content)
self.deleted_files.remove(path)
set_readonly(path)
print("{} - was delete, reverted".format(to_relative_path(path)))
return 0
def add_file(self, path):
path = os.path.abspath(path)
if path in self.deleted_files:
print("{} - can't add (already opened for delete)"
.format(to_relative_path(path)))
elif path in self.opened_files:
print("{} - can't add (already opened for edit)"
.format(to_relative_path(path)))
elif not path in self.added_files:
self.added_files.append(path)
print("{} - opened for add".format(to_relative_path(path)))
else:
print("{} - currently opened for add".format(to_relative_path(path)))
return 0
def delete_file(self, path):
path = os.path.abspath(path)
if path in self.added_files or path in self.opened_files:
print("{} - currently opened for delete"
.format(to_relative_path(path)))
elif not path in self.deleted_files:
self.deleted_files.append(CachedFile(path))
print("{} - opened for delete".format(to_relative_path(path)))
os.remove(path)
return 0
def cache_args(argc, argv, cache_path):
args = argv[1:]
# If we have less than two arguments, we can't work our magic.
if argc < 2:
return forward_to_p4(argc, argv)
if os.path.isfile(cache_path):
cached = Cache.load_from_file(cache_path)
else:
cached = Cache()
def status():
if len(cached.added_files) == 0 and \
len(cached.opened_files) == 0 and \
len(cached.deleted_files) == 0:
print("no files opened for edit, add or delete")
else:
for i in cached.added_files:
print("{} - add".format(to_relative_path(i)))
for i in cached.opened_files:
print("{} - edit".format(to_relative_path(i)))
for i in cached.deleted_files:
print("{} - deleted".format(to_relative_path(i)))
return 0
returncode = -1
if args[0] == "add":
returncode = cached.add_file(args[1])
elif args[0] == "edit":
returncode = cached.open_file(args[1])
elif args[0] == "delete":
returncode = cached.delete_file(args[1])
elif args[0] == "revert":
returncode = cached.revert_file(args[1])
elif args[0] == "status":
returncode = status()
# Save to disk!
if returncode != -1:
cached.save_to_file(cache_path)
return returncode
return forward_to_p4(argc, argv)
# =============================================================================
# Replaying of offline cache
# =============================================================================
def replay_offline_cache(cache_path):
def p4_cmd(cmd, args):
argv = [ "p4", cmd ]
if len(args) < 1:
return 0
argv.extend(args)
p = subprocess.run(argv)
return p.returncode
cached = Cache.load_from_file(cache_path)
if p4_cmd("add", cached.added_files) != 0:
return 1
if p4_cmd("edit", cached.opened_files) != 0:
return 1
if p4_cmd("delete", cached.deleted_files) != 0:
return 1
os.remove(cache_path)
return 0
# =============================================================================
# Entry Point
# =============================================================================
def main(argc, argv):
root = find_root()
# TODO: Add a config file, to allow configuring this?
# TODO: Add a 'help' command? Intercept 'p4 help' and inject 'p4 help replay'.
# As well as intercepting 'p4 help replay' and showing the help from above.
cache_path = os.path.abspath(os.path.join(root, ".p4cache"))
# Manually replaying the offline cached commands.
if argc > 1 and argv[1] == "replay":
return replay_offline_cache(cache_path)
# Detect whether we have network, forward to p4 if yes, cache otherwise.
if has_internet():
if os.path.isfile(cache_path):
replay_offline_cache(cache_path)
return forward_to_p4(argc, argv)
else:
return cache_args(argc, argv, cache_path)
if __name__ == "__main__":
sys.exit(main(len(sys.argv), sys.argv))
#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
set -x
# Setup
TEMPDIR=$(mktemp -d)
cd $TEMPDIR
# Marks the P4 root of a depot.
touch .p4ignore
# Cleanup on exit.
function onExit {
cd $DIR
rm -rf "$TEMPDIR"
}
trap onExit EXIT
# =============================================================================
# Tests
# =============================================================================
# !! ATTENTION !!
# The following tests assume that there is never an internet connection!
# Or that the 'p4.py' script has been modified to 'return False`
# from 'has_internet()' in all cases
# 1.1 'p4 add'
echo "123test123" > add.txt
$DIR/p4.py add add.txt | grep -E "add.txt \- opened for add\>"
$DIR/p4.py status | grep -E "add.txt \- add\>"
$DIR/p4.py revert add.txt | grep -E "add.txt \- was add, abandoned\>"
cat add.txt | grep -E "\<123test123\>"
$DIR/p4.py status | grep -E "\<no files opened for edit, add or delete\>"
# 1.2: 'p4 edit'
$DIR/p4.py edit add.txt | grep -E "add.txt \- opened for edit\>"
$DIR/p4.py status | grep -E "add.txt \- edit\>"
echo "" > add.txt
$DIR/p4.py revert add.txt | grep -E "add.txt \- was edit, reverted\>"
cat add.txt | grep -E "\<123test123\>"
$DIR/p4.py status | grep -E "\<no files opened for edit, add or delete\>"
# 1.3: 'p4 delete'
$DIR/p4.py delete add.txt | grep -E "add.txt \- opened for delete\>"
if [ -f "add.txt" ]; then exit 1; fi
$DIR/p4.py status | grep -E "add.txt \- deleted\>"
$DIR/p4.py revert add.txt | grep -E "add.txt \- was delete, reverted\>"
cat add.txt | grep -E "\<123test123\>"
$DIR/p4.py status | grep -E "\<no files opened for edit, add or delete\>"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment