Skip to content

Commit 0938344

Browse files
committed
UPDATED: split code in many files, added demos, completed basic API GitHub service using PyGitHub
1 parent eab32b3 commit 0938344

File tree

10 files changed

+637
-310
lines changed

10 files changed

+637
-310
lines changed

src/ArgumentParserService.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import os
4+
from datetime import datetime
5+
6+
from FileService import is_file_directory_writable, is_file_writable
7+
from defines import FlattenLevel, CollisionStrategy
8+
import argparse
9+
10+
11+
def build_argument_parser():
12+
parser = argparse.ArgumentParser(description="Backup your git repos in your local filesystem")
13+
14+
# Optional arguments
15+
parser.add_argument("-p", "--provider", "--providers",
16+
help="Adds custom GitLab providers",
17+
type=str,
18+
nargs="+",
19+
dest="custom_providers")
20+
parser.add_argument("-f", "--force",
21+
help="Does not ask confirmation for destructive actions.",
22+
# type=bool,
23+
# nargs=0,
24+
dest="is_forced",
25+
action="store_true",
26+
default=False)
27+
parser.add_argument("-r", "--remove", "--remove-folder",
28+
help="Removes the backup content after the backup.",
29+
# type=bool,
30+
# nargs=0,
31+
dest="remove_backup_folder_afterwards",
32+
action="store_true",
33+
default=False)
34+
parser.add_argument("-s", "--scratch", "--from-scratch",
35+
help="Empties the backup folder before starting the backup.",
36+
# type=bool,
37+
# nargs=0,
38+
dest="empty_backup_folder_first",
39+
action="store_true",
40+
default=False)
41+
parser.add_argument("-c", "--compress",
42+
help="Produces a compressed file with the backup folders.",
43+
# type=bool,
44+
# nargs=0,
45+
dest="produce_compressed",
46+
action="store_true",
47+
default=False)
48+
parser.add_argument("-C", "--compressed-path",
49+
help="Custom path where the compressed file will be stored. Implies -c.",
50+
type=str,
51+
nargs='?',
52+
dest="compressed_path",
53+
metavar="FILE_PATH")
54+
parser.add_argument("-y", "--hierarchy", "--hierarchy-backup", "--keep-hierarchy",
55+
help="Clone repos keeping organizations and user hierarchy in the backup folder.",
56+
# type=bool,
57+
# nargs=0,
58+
dest="reflect_hierarchy",
59+
action="store_true",
60+
default=False)
61+
parser.add_argument("-F", "--flatten", "--Flatten",
62+
help="Hierarchy levels of the backup that will not be present in the hierarchical structure"
63+
" of the folder "
64+
"structure of the backup. Implies -y except when "
65+
"flattening all directory level:" +
66+
FlattenLevel.ROOT.name + ": Flattens root folder in the backup." +
67+
FlattenLevel.USER.name + ": Flattens user folders in the backup" +
68+
FlattenLevel.PROVIDER.name + ": Flattens provider folder in the backup." +
69+
FlattenLevel.ORGANIZATION.name + ": Flattens organization folder in the backup.",
70+
type=str,
71+
nargs="+",
72+
dest="flatten_directories",
73+
choices=[name for name in FlattenLevel])
74+
parser.add_argument("-l", "--handle-collision", "--handle-collision-strategy",
75+
help="Strategy to follow to handle collisions (a repo that because of its name has to be cloned"
76+
" in the same folder path as another one):" +
77+
CollisionStrategy.RENAME.name + ": Use a systematic name (the shortest possible) for the "
78+
"new repo that produces the collision" +
79+
CollisionStrategy.SYSTEMATIC.name + ": Use a systematic name for all repos" +
80+
CollisionStrategy.IGNORE.name + ": Ignores repos that have filename collisions." +
81+
CollisionStrategy.REMOVE.name + ": Removes the repo already cloned and clones the new one "
82+
"with the same name.",
83+
type=str,
84+
nargs='?',
85+
dest="collision_strategy",
86+
choices=[name for name in CollisionStrategy])
87+
parser.add_argument("-j", "--json", "--generate-json", "--produce-json",
88+
help="Generates a JSON report of the backup folders and repos.",
89+
# type=bool,
90+
# nargs=0,
91+
dest="produce_json",
92+
action="store_true",
93+
default=False)
94+
parser.add_argument("-J", "--json-path",
95+
help="Custom path where the JSON report will be stored. Implies -j. Requires a value.",
96+
type=str,
97+
nargs='?',
98+
dest="json_path",
99+
metavar="FILE_PATH")
100+
parser.add_argument("-b", "--backup-directory", "--backup-folder",
101+
help="Custom folder where the backups will be stored.",
102+
type=str,
103+
nargs='?',
104+
dest="backup_folder",
105+
metavar="DIRECTORY_PATH")
106+
parser.add_argument("-n", "--backup-name",
107+
help="Custom name for the root backup folder.",
108+
type=str,
109+
nargs='?',
110+
dest="backup_name",
111+
metavar="DIRECTORY_NAME")
112+
parser.add_argument("-v", "--verbose",
113+
help="Enable verbose output during backup.",
114+
# type=bool,
115+
# nargs=0,
116+
dest="is_verbose",
117+
action="store_true",
118+
default=False)
119+
parser.add_argument("--exclude-github",
120+
help="Exclude GitHub repositories from the backup.",
121+
# type=bool,
122+
# nargs=0,
123+
dest="exclude_github",
124+
action="store_true"),
125+
parser.add_argument("--exclude-gitlab",
126+
help="Exclude GitLab repositories from the backup.",
127+
# type=bool,
128+
# nargs=0,
129+
dest="exclude_gitlab",
130+
action="store_true")
131+
parser.add_argument("--exclude-enterprise",
132+
help="Exclude \"official\" providers (github.com and gitlab.com).",
133+
# type=bool,
134+
# nargs=0,
135+
dest="exclude_enterprise",
136+
action="store_true")
137+
# Positional argument for usernames of the profiles to scrap
138+
parser.add_argument("usernames",
139+
help="List of usernames to back up.",
140+
# type=List[str],
141+
nargs="+",
142+
# dest="usernames",
143+
metavar="USERNAME1 USERNAME2 USERNAME3 ...")
144+
return parser
145+
146+
147+
def parse_arguments(parser: argparse.ArgumentParser):
148+
args = parser.parse_args()
149+
150+
if not args.backup_name:
151+
args.backup_name = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") # ISO 8601-like format
152+
153+
# Supply default backup directory
154+
if not args.backup_folder:
155+
args.backup_folder = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backup")
156+
157+
# Check existence and access of backup directory
158+
if not os.path.exists(args.backup_folder):
159+
try:
160+
os.makedirs(args.backup_folder)
161+
except OSError as e:
162+
parser.error(f"The folder for the backup {args.backup_folder} does not exist and cannot be created. Error: "
163+
+ e.__str__())
164+
else:
165+
if not os.access(args.backup_folder, os.W_OK):
166+
parser.error(f"The folder for the backup {args.backup_folder} cannot be written or there is some problem "
167+
f"with it: ")
168+
169+
# Flattening all directory levels makes hierarchy disappears, so it makes no sense to select both configurations
170+
if args.flatten_directories \
171+
and "rename" in args.flatten_directories \
172+
and "ignore" in args.flatten_directories \
173+
and "remove" in args.flatten_directories \
174+
and args.reflect_hierarchy:
175+
parser.error("You cannot use -y when flattening all directory levels with -F because it makes no sense.")
176+
177+
# When keeping the complete hierarchy we ensure that there will be no collisions
178+
if not args.flatten_directories \
179+
and args.reflect_hierarchy \
180+
and args.collision_strategy:
181+
parser.error("You do not need to supply a collision strategy with -l when keeping the hierarchy with -y and not"
182+
" flattening any directory (not supplying -F)")
183+
184+
# Supply default collision strategy if needed
185+
if args.reflect_hierarchy \
186+
and args.flatten_directories \
187+
and not args.collision_strategy:
188+
args.collision_strategy = "rename"
189+
190+
if not args.collision_strategy:
191+
args.collision_strategy = "rename"
192+
193+
# If path supplied -c implicit
194+
if not args.produce_compressed and args.compressed_path:
195+
args.produce_compressed = True
196+
197+
# If -c but no path provided set to default value
198+
if args.produce_compressed and not args.compressed_path:
199+
args.compressed_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backup" + args.backup_name +
200+
".tar.gz")
201+
202+
# Check access to json file
203+
if args.compressed_path and not is_file_directory_writable(args.compressed_path):
204+
parser.error("File " + args.compressed_path + " is not writable because its directory cannot be accessed.")
205+
206+
# Check write access to json file
207+
if args.compressed_path and not is_file_writable(args.compressed_path):
208+
parser.error("File " + args.compressed_path + " is not writable.")
209+
210+
# If path supplied -j implicit
211+
if not args.produce_json and args.json_path:
212+
args.produce_json = True
213+
214+
# If -j but no path provided set to default value
215+
if args.produce_json and not args.json_path:
216+
args.json_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backup" + args.backup_name + ".json")
217+
218+
# Check access to json file
219+
if args.json_path and not is_file_directory_writable(args.json_path):
220+
parser.error("File " + args.json_path + " is not writable because its directory cannot be accessed.")
221+
222+
# Check write access to json file
223+
if args.json_path and not is_file_writable(args.json_path):
224+
parser.error("File " + args.json_path + " is not writable.")
225+
226+
# Check if at least one source is included
227+
if args.exclude_github and args.exclude_gitlab:
228+
parser.error("You cannot exclude both GitHub and GitLab. At least one provider must be included.")
229+
230+
if args.custom_providers:
231+
custom_providers = []
232+
for custom_provider in args.custom_providers:
233+
if args.exclude_github:
234+
custom_providers.append({'url': custom_provider, 'provider': "GitLab"})
235+
if args.exclude_gitlab:
236+
custom_providers.append({'url': custom_provider, 'provider': "GitHub"})
237+
return args

src/FileService.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import os
5+
6+
7+
def is_file_directory_writable(file_path):
8+
if os.access(os.path.dirname(file_path), os.W_OK):
9+
return True
10+
else:
11+
return False
12+
13+
14+
def is_file_writable(file_path):
15+
try:
16+
# Try opening file in write mode
17+
open(file_path, 'w')
18+
return True
19+
except (OSError, PermissionError):
20+
return False

src/GitHubService.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from typing import Optional, List
2+
3+
from ProviderService import ProviderService, build_provider
4+
from github import Github
5+
from github import Auth
6+
7+
from defines import ProviderType
8+
9+
10+
class GitHubService(ProviderService):
11+
"""Service for interacting with GitHub."""
12+
13+
def __init__(self, access_token, url: Optional[str] = None):
14+
if url:
15+
self.g = Github(base_url=url, auth=Auth.Token(access_token))
16+
else:
17+
self.g = Github(auth=Auth.Token(access_token))
18+
19+
def get_user_organizations(self):
20+
user_orgs = []
21+
for orgs in self.g.get_user().get_orgs():
22+
user_orgs.append(orgs)
23+
self.g.close()
24+
return user_orgs
25+
26+
def get_user_organization_names(self, username) -> List[str]:
27+
return [org.login for org in self.get_user_organizations()]
28+
29+
def get_user_repos(self):
30+
user = self.g.get_user()
31+
return user.get_repos()
32+
33+
def get_user_owned_repos(self, username):
34+
user_repos = self.g.get_user(username).get_repos()
35+
owned_repos = [repo for repo in user_repos if repo.owner.login == username]
36+
self.g.close()
37+
return owned_repos
38+
39+
def get_user_owned_repo_names(self, username) -> List[str]:
40+
return [repo.name for repo in self.get_user_owned_repos(username)]
41+
42+
def get_user_collaboration_repos(self, username):
43+
user_repos = self.get_user_repos()
44+
org_names = self.get_user_organization_names(username)
45+
owned_repos = [repo for repo in user_repos
46+
if repo.owner.login != username and repo.owner.login not in org_names]
47+
self.g.close()
48+
return owned_repos
49+
50+
def get_user_collaboration_repo_names(self, username) -> List[str]:
51+
return [repo.name for repo in self.get_user_collaboration_repos(username)]
52+
53+
def get_organization_repos(self, organization):
54+
return self.get_user_owned_repos(organization)
55+
56+
def get_organization_repo_names(self, organization) -> List[str]:
57+
return [repo.name for repo in self.get_user_owned_repos(organization)]
58+
59+
60+
def build_github_official_provider():
61+
return build_provider('https://github.com/', ProviderType.GITHUB)

src/GitLabService.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from typing import List
2+
3+
from ProviderService import ProviderService, build_provider
4+
from defines import ProviderType
5+
6+
7+
class GitLabService(ProviderService):
8+
"""Service for interacting with GitLab."""
9+
10+
def __init__(self, access_token):
11+
self.access_token = access_token
12+
# Assume some GitLab API client initialization here
13+
14+
def get_user_organization_names(self, username) -> List[str]:
15+
pass
16+
17+
def get_user_owned_repo_names(self, username) -> List[str]:
18+
pass
19+
20+
def get_user_collaboration_repo_names(self, username) -> List[str]:
21+
pass
22+
23+
def get_organization_repo_names(self, organization) -> List[str]:
24+
pass
25+
26+
27+
def build_gitlab_official_provider():
28+
return build_provider('https://gitlab.com/', ProviderType.GITLAB)

src/ProviderService.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from abc import ABC, abstractmethod
2+
from typing import List
3+
4+
5+
class ProviderService(ABC):
6+
"""Interface for provider services like GitHub and GitLab."""
7+
8+
def get_user_organization_names(self, username) -> List[str]:
9+
pass
10+
11+
def get_user_owned_repo_names(self, username) -> List[str]:
12+
pass
13+
14+
def get_user_collaboration_repo_names(self, username) -> List[str]:
15+
pass
16+
17+
def get_organization_repo_names(self, organization) -> List[str]:
18+
pass
19+
20+
21+
def build_provider(url, provider_type):
22+
return {'url': url, 'type': provider_type, 'orgs': {}}

0 commit comments

Comments
 (0)