Skip to content

Commit a5cdb6f

Browse files
committed
SSH support, YAML instead of JSON
1 parent 145b580 commit a5cdb6f

6 files changed

Lines changed: 161 additions & 36 deletions

File tree

Readme.md

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,53 @@
11
PyPiGit
22
=======
33

4-
A simple PyPi-like server that uses git repositories as a source of python packages.
4+
A simple PyPi-like server that automatically generates python packages from git tags.
55

66
How To
77
------
88

9-
* Prepare `repos.json`:
10-
```json
11-
{
12-
"repositories": [
13-
"https://github.com/<author>/<repo>.git",
14-
"git@github.com:<author>/<repo>.git"
15-
]
16-
}
9+
* Prepare `repos.yaml`:
10+
```yaml
11+
repositories:
12+
- https://github.com/<author>/<repo>.git
13+
- git@github.com:<author>/<repo>.git
1714
```
1815
19-
* Start a server:
16+
* Start a server (see `python -m pypigit.server --help` for all arguments):
2017
```bash
21-
python -m pypigit.server --repos="repos.json"
18+
python -m pypigit.server --repos="repos.yaml"
2219
```
2320

2421
* Draft a release (that will create certain tag)
22+
* Install a package with pip:
2523
```bash
2624
pip install --extra-index-url http://localhost:9498/simple <repo>==1.0.2
2725
```
2826

29-
It will automatically clone tag `1.0.2`, build a `sdist` and cache it.
27+
It will automatically build a package from tag `1.0.2`, and deliver it to pip. Once
28+
built, it will be stored in `--cache-direcory` so consecutive calls will take no time.
29+
30+
SSH
31+
---
32+
33+
This project is aimed to host private packages, so ssh git remotes are supported:
34+
```yaml
35+
repositories:
36+
- url: git@github.com:<author>/<repo>.git
37+
ssh_key: |
38+
-----BEGIN RSA PRIVATE KEY-----
39+
<private key>
40+
-----END RSA PRIVATE KEY-----
41+
```
42+
If you have same private ssh key for all repositories, you can simply define `default_ssh_key` instead:
43+
```yaml
44+
repositories:
45+
- git@github.com:<author>/<repo>.git
46+
- git@github.com:<author>/<repo>.git
47+
- git@github.com:<author>/<repo>.git
48+
default_ssh_key: |
49+
-----BEGIN RSA PRIVATE KEY-----
50+
<private key>
51+
-----END RSA PRIVATE KEY-----
52+
53+
```

pypigit/__init__.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11

22
from tornado.ioloop import IOLoop
3+
from tempfile import mktemp
4+
import os
35

46

57
def run_on_executor(method):
@@ -8,3 +10,51 @@ def wrapper(self, *args):
810
return IOLoop.current().run_in_executor(executor, method, self, *args)
911
return wrapper
1012

13+
14+
class PrivateSSHKeyContext(object):
15+
"""
16+
This context manager class creates temporary file with ssh_private_key in it,
17+
and conveniently returns path to it, taking care to remove the file afterwards:
18+
19+
with PrivateSSHKeyWrapper("private ssh key string") as key_path:
20+
use key_path here for ssh operations
21+
22+
key_path deleted afterwards
23+
24+
"""
25+
26+
def __init__(self, ssh_private_key=None):
27+
self.name = None
28+
self.ssh_private_key = ssh_private_key
29+
self.sys_fd = None
30+
31+
@staticmethod
32+
def convert_path(path):
33+
separator = os.path.sep
34+
if separator != '/':
35+
path = path.replace(os.path.sep, '/')
36+
return path
37+
38+
def __enter__(self):
39+
if self.ssh_private_key is None:
40+
return None
41+
42+
self.name = mktemp()
43+
44+
with open(self.name, 'w') as f:
45+
f.write(self.ssh_private_key)
46+
f.write("\n")
47+
48+
return PrivateSSHKeyContext.convert_path(self.name)
49+
50+
def __exit__(self, exc, value, tb):
51+
self.cleanup()
52+
53+
def cleanup(self):
54+
if self.name is None:
55+
return
56+
57+
try:
58+
os.remove(self.name)
59+
except IOError:
60+
pass

pypigit/handler.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11

22
from tornado.web import RequestHandler, HTTPError
3+
from . repos import GitRepositoryError
34

45

56
class MainHandler(RequestHandler):
@@ -20,7 +21,10 @@ async def get(self, package_name):
2021
if repo is None:
2122
raise HTTPError(404, "No such package")
2223

23-
versions = await repo.list_versions()
24+
try:
25+
versions = await repo.list_versions()
26+
except GitRepositoryError as e:
27+
raise HTTPError(e.code, e.message)
2428

2529
self.render("templates/package_versions.html", package_name=package_name, versions=versions)
2630

pypigit/repos.py

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,56 @@
11

2-
from . import run_on_executor
2+
from . import run_on_executor, PrivateSSHKeyContext
3+
34
import giturlparse
4-
import json
55
from concurrent.futures import ThreadPoolExecutor
66
import git
77
import os
88
import os.path
99
import subprocess
1010
import stat
11+
import yaml
12+
1113
from shutil import copyfile, rmtree
1214
from tempfile import TemporaryDirectory
1315

1416

17+
def git_ssh_command(private_key):
18+
return "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {0}".format(private_key)
19+
20+
21+
def git_ssh_environment(g, ssh_private_key_filename=None):
22+
if ssh_private_key_filename:
23+
return g.custom_environment(GIT_SSH_COMMAND=git_ssh_command(ssh_private_key_filename))
24+
return g.custom_environment()
25+
26+
1527
class CacheRedirectException(Exception):
1628
def __init__(self, url):
1729
self.url = url
1830

1931

32+
class GitRepositoryError(Exception):
33+
def __init__(self, code, message):
34+
self.code = code
35+
self.message = message
36+
37+
2038
class GitRepository(object):
2139
executor = ThreadPoolExecutor()
22-
g = git.cmd.Git()
2340

24-
def __init__(self, url, cache_directory, public_url):
41+
def __init__(self, settings, cache_directory, public_url, default_private_key=None):
42+
43+
if isinstance(settings, str):
44+
url = settings
45+
self.private_key = None
46+
elif isinstance(settings, dict):
47+
url = settings.get("url", None)
48+
if url is None:
49+
raise RuntimeError("Repo is an object but has no `url` property")
50+
self.private_key = settings.get("ssh_key", default_private_key)
51+
else:
52+
raise RuntimeError("Repo should be a string or a object")
53+
2554
self.repo_url = url
2655
self.parsed_url = giturlparse.parse(url)
2756
if not self.parsed_url.valid:
@@ -33,14 +62,20 @@ def __init__(self, url, cache_directory, public_url):
3362
@run_on_executor
3463
def list_versions(self):
3564
repo_name = self.name()
36-
tags = GitRepository.g.ls_remote(self.repo_url, tags=True)
37-
result = {}
38-
for line in tags.split('\n'):
39-
ref_hash, ref = line.split('\t')
40-
tag_name = ref.split("/")[-1]
41-
tar_name = self.package_tar(tag_name)
42-
result[tar_name] = self.public_url + "/download/" + repo_name + "/" + tar_name
43-
return result
65+
with PrivateSSHKeyContext(ssh_private_key=self.private_key) as ssh_private_key_filename:
66+
g = git.cmd.Git()
67+
with git_ssh_environment(g, ssh_private_key_filename=ssh_private_key_filename):
68+
try:
69+
tags = g.ls_remote(self.repo_url, tags=True)
70+
except git.GitCommandError as e:
71+
raise GitRepositoryError(500, "Failed to fetch remote repository: {0}".format(e.status))
72+
result = {}
73+
for line in tags.split('\n'):
74+
ref_hash, ref = line.split('\t')
75+
tag_name = ref.split("/")[-1]
76+
tar_name = self.package_tar(tag_name)
77+
result[tar_name] = self.public_url + "/download/" + repo_name + "/" + tar_name
78+
return result
4479

4580
def get_cache_url(self, package_version):
4681
return self.cache_public_url + "/" + self.package_tar(package_version)
@@ -59,20 +94,26 @@ def download(self, package_version):
5994
if os.path.isfile(os.path.join(self.cache_directory, tar_name)):
6095
return cache_url
6196

97+
# noinspection PyUnusedLocal
6298
def set_rw(operation, name, exc):
6399
os.chmod(name, stat.S_IWRITE)
64100
os.remove(name)
65101
return True
66102

67103
with TemporaryDirectory(prefix="pypigit") as temp_dir:
68-
r = git.Git(temp_dir)
69-
r.clone(self.repo_url, branch=package_version, depth=1)
104+
g = git.Git(temp_dir)
105+
106+
with PrivateSSHKeyContext(ssh_private_key=self.private_key) as ssh_private_key_filename:
107+
with git_ssh_environment(g, ssh_private_key_filename=ssh_private_key_filename):
108+
g.clone(self.repo_url, branch=package_version, depth=1)
109+
70110
build = os.path.join(temp_dir, repo_name)
71111
p = subprocess.Popen("python setup.py sdist", stdout=subprocess.PIPE, shell=True, cwd=build)
72112
p.communicate()
73113

74114
copyfile(os.path.join(build, "dist", tar_name), os.path.join(self.cache_directory, tar_name))
75115

116+
# noinspection PyTypeChecker
76117
rmtree(build, onerror=set_rw)
77118

78119
return cache_url
@@ -85,18 +126,23 @@ class GitRepositories(object):
85126
def __init__(self, repos_filename, cache_directory, public_url):
86127

87128
with open(repos_filename, "r") as f:
88-
repos = json.load(f)
129+
repos = yaml.load(f)
89130

90131
if not isinstance(repos, dict):
91132
raise RuntimeError("--repos file should be a json object")
92133

134+
default_private_key = repos.get("default_ssh_key")
135+
93136
if "repositories" not in repos:
94137
raise RuntimeError("No 'repositories' section in --repos file")
95138

96139
self.repositories = {
97140
repo.name(): repo
98-
for repo in map(lambda path: GitRepository(path, cache_directory, public_url),
99-
filter(lambda s: isinstance(s, str), repos["repositories"]))
141+
for repo in map(
142+
lambda settings: GitRepository(
143+
settings, cache_directory, public_url, default_private_key=default_private_key
144+
), repos["repositories"]
145+
)
100146
}
101147

102148
def find(self, name):

pypigit/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
define("port", type=int, default="9498", help="Listen port")
1212
define("cache-directory", type=str, default=".pypigit", help="Cache directory")
1313
define("public-url", type=str, default="http://localhost:9498", help="Public URL directory")
14-
define("repos", type=str, help="JSON file with list of git remotes")
14+
define("repos", type=str, help="YAML file with list of git remotes")
1515

1616

1717
class PyPiGITServer(Application):

setup.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
33

44
setup(
55
name='pypigit',
6-
version='0.1.0',
7-
description='A simple PyPi-like server that uses git repositories as a source of python packages',
6+
version='0.2',
7+
description='A simple PyPi-like server that automatically generates python packages from git tags',
88
author='desertkun',
99
license='MIT',
1010
author_email='desertkun@gmail.com',
1111
url='https://github.com/anthill-utils/pypigit',
1212
packages=find_packages(),
1313
zip_safe=False,
1414
install_requires=[
15-
"tornado==5.1.1",
16-
"GitPython==2.1.7",
17-
"giturlparse.py==0.0.5"
15+
"tornado>=5.0",
16+
"GitPython>=2.1.7",
17+
"giturlparse.py>=0.0.5",
18+
"PyYAML>=3.13"
1819
]
1920
)

0 commit comments

Comments
 (0)