Skip to content

Commit 86648da

Browse files
committed
feat: add new functions and classes from dotfiles
1 parent 59a73a9 commit 86648da

8 files changed

Lines changed: 309 additions & 65 deletions

File tree

clit/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Configuration helpers."""
2+
import json
3+
4+
from clit.constants import CONFIG_DIR
5+
6+
7+
class JsonConfig:
8+
"""A JSON config file."""
9+
10+
def __init__(self, partial_path):
11+
"""Create or get a JSON config file inside the config directory."""
12+
self.full_path = CONFIG_DIR / partial_path
13+
self.full_path.parent.mkdir(parents=True, exist_ok=True)
14+
15+
def _generic_load(self, default):
16+
"""Try to load file data, and use a default when there is no data."""
17+
try:
18+
data = json.loads(self.full_path.read_text())
19+
except (json.decoder.JSONDecodeError, FileNotFoundError):
20+
data = default
21+
return data
22+
23+
def load_set(self):
24+
"""Load file data as a set."""
25+
return set(self._generic_load(set()))
26+
27+
def dump(self, new_data):
28+
"""Dump new JSON data in the config file."""
29+
if isinstance(new_data, set):
30+
new_data = list(new_data)
31+
self.full_path.write_text(json.dumps(new_data))

clit/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Constants."""
2+
from pathlib import Path
3+
4+
SECTION_SYMLINKS_FILES = "symlinks/files"
5+
SECTION_SYMLINKS_DIRS = "symlinks/dirs"
6+
PYCHARM_APP_FULL_PATH = "/Applications/PyCharm.app/Contents/MacOS/pycharm"
7+
CONFIG_DIR = Path("~/.config/dotfiles/").expanduser()

clit/db.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Database module."""
2+
from typing import List, Optional
3+
4+
from clit.files import shell
5+
6+
7+
class DatabaseServer:
8+
"""A database server URI parser."""
9+
10+
uri: str
11+
protocol: str
12+
user: Optional[str]
13+
password: Optional[str]
14+
server: str
15+
port: Optional[int]
16+
17+
def __init__(self, uri):
18+
"""Parser the server URI and extract needed parts."""
19+
self.uri = uri
20+
protocol_user_password, server_port = uri.split("@")
21+
self.protocol, user_password = protocol_user_password.split("://")
22+
if ":" in user_password:
23+
self.user, self.password = user_password.split(":")
24+
else:
25+
self.user, self.password = None, None
26+
if ":" in server_port:
27+
self.server, self.port = server_port.split(":")
28+
self.port = int(self.port)
29+
else:
30+
self.server, self.port = server_port, None
31+
32+
@property
33+
def uri_without_port(self):
34+
"""Return the URI without the port."""
35+
parts = self.uri.split(":")
36+
if len(parts) != 4:
37+
# Return the unmodified URI if we don't have port.
38+
return self.uri
39+
return ":".join(parts[:-1])
40+
41+
42+
class PostgreSQLServer(DatabaseServer):
43+
"""A PostgreSQL database server URI parser and more stuff."""
44+
45+
databases: List[str] = []
46+
inside_docker = False
47+
psql: str = ""
48+
pg_dump: str = ""
49+
50+
def __init__(self, *args, **kwargs):
51+
"""Determine which psql executable exists on this machine."""
52+
super().__init__(*args, **kwargs)
53+
54+
self.psql = shell("which psql", quiet=True, capture_output=True).stdout
55+
if not self.psql:
56+
self.psql = "psql_docker"
57+
self.inside_docker = True
58+
59+
self.pg_dump = shell("which pg_dump", quiet=True, capture_output=True).stdout
60+
if not self.pg_dump:
61+
self.pg_dump = "pg_dump_docker"
62+
self.inside_docker = True
63+
64+
@property
65+
def docker_uri(self):
66+
"""Return a URI without port if we are inside Docker."""
67+
return self.uri_without_port if self.inside_docker else self.uri
68+
69+
def list_databases(self) -> "PostgreSQLServer":
70+
"""List databases."""
71+
process = shell(
72+
f"{self.psql} -c 'SELECT datname FROM pg_database WHERE datistemplate = false' "
73+
f"--tuples-only {self.docker_uri}",
74+
quiet=True,
75+
capture_output=True,
76+
)
77+
if process.returncode:
78+
print(f"Error while listing databases.\nstdout={process.stdout}\nstderr={process.stderr}")
79+
exit(10)
80+
81+
self.databases = sorted(db.strip() for db in process.stdout.strip().split())
82+
return self

clit/dev.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Development helpers."""
2+
import os
3+
from subprocess import call, check_output
4+
from typing import Tuple
5+
6+
import click
7+
import crayons
8+
from plumbum import FG, RETCODE
9+
10+
from clit.constants import PYCHARM_APP_FULL_PATH
11+
12+
13+
@click.command()
14+
@click.argument("files", nargs=-1)
15+
def pycharm_cli(files):
16+
"""Invoke PyCharm on the command line.
17+
18+
If a file doesn't exist, call `which` to find out the real location.
19+
"""
20+
full_paths = []
21+
for possible_file in files:
22+
if os.path.isfile(possible_file):
23+
real_file = os.path.abspath(possible_file)
24+
else:
25+
real_file = check_output(["which", possible_file]).decode().strip()
26+
full_paths.append(real_file)
27+
command_line = [PYCHARM_APP_FULL_PATH] + full_paths
28+
print(crayons.green("Calling PyCharm with {}".format(" ".join(command_line))))
29+
call(command_line)
30+
31+
32+
@click.command()
33+
@click.option("--delete", "-d", default=False, is_flag=True, help="Delete pytest directory first")
34+
@click.option("--failed", "-f", default=False, is_flag=True, help="Run only failed tests")
35+
@click.option("--count", "-c", default=0, help="Repeat the same test several times")
36+
@click.option("--reruns", "-r", default=0, help="Re-run a failed test several times")
37+
@click.argument("class_names_or_args", nargs=-1)
38+
def pytest_run(delete: bool, failed: bool, count: int, reruns: int, class_names_or_args: Tuple[str]):
39+
"""Run pytest with some shortcut options."""
40+
# Import locally, so we get an error only in this function, and not in other functions of this module.
41+
from plumbum.cmd import time as time_cmd, rm
42+
43+
if delete:
44+
print(crayons.green("Removing .pytest directory", bold=True))
45+
rm["-rf", ".pytest"] & FG
46+
47+
pytest_plus_args = ["pytest", "-vv", "--run-intermittent"]
48+
if reruns:
49+
pytest_plus_args.extend(["--reruns", str(reruns)])
50+
if failed:
51+
pytest_plus_args.append("--failed")
52+
53+
if count:
54+
pytest_plus_args.extend(["--count", str(count)])
55+
56+
if class_names_or_args:
57+
targets = []
58+
for name in class_names_or_args:
59+
if "." in name:
60+
parts = name.split(".")
61+
targets.append("{}.py::{}".format("/".join(parts[0:-1]), parts[-1]))
62+
else:
63+
# It might be an extra argument, let's just append it
64+
targets.append(name)
65+
pytest_plus_args.append("-s")
66+
pytest_plus_args.extend(targets)
67+
68+
print(crayons.green("Running tests: time {}".format(" ".join(pytest_plus_args)), bold=True))
69+
rv = time_cmd[pytest_plus_args] & RETCODE(FG=True)
70+
exit(rv)

clit/docker.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Docker module."""
2+
import json
3+
from pathlib import Path
4+
from typing import List
5+
6+
from clit.files import shell
7+
from clit.types import JsonDict
8+
9+
10+
class DockerContainer:
11+
"""A helper for Docker containers."""
12+
13+
def __init__(self, container_name: str) -> None:
14+
"""Init instance."""
15+
self.container_name = container_name
16+
self.inspect_json: List[JsonDict] = []
17+
18+
def inspect(self) -> "DockerContainer":
19+
"""Inspect a Docker container and save its JSON info."""
20+
if not self.inspect_json:
21+
raw_info = shell(f"docker inspect {self.container_name}", quiet=True, capture_output=True).stdout
22+
self.inspect_json = json.loads(raw_info)
23+
return self
24+
25+
def replace_mount_dir(self, path: Path) -> Path:
26+
"""Replace a mounted dir on a file/dir path inside a Docker container."""
27+
self.inspect()
28+
for mount in self.inspect_json[0]["Mounts"]:
29+
source = mount["Source"]
30+
if str(path).startswith(source):
31+
new_path = str(path).replace(source, mount["Destination"])
32+
return Path(new_path)
33+
return path

clit/files.py

Lines changed: 61 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
# -*- coding: utf-8 -*-
22
"""Files, symbolic links, operating system utilities."""
33
import os
4+
from argparse import ArgumentTypeError
5+
from pathlib import Path
46
from shlex import split
5-
from subprocess import call, check_output
6-
from typing import List, Tuple
7+
from subprocess import PIPE, run
8+
from time import sleep
9+
from typing import List
710

811
import click
912
import crayons
10-
from plumbum import FG, RETCODE
13+
from plumbum import FG
1114

1215
from clit import CONFIG, LOGGER, read_config, save_config
13-
14-
SECTION_SYMLINKS_FILES = "symlinks/files"
15-
SECTION_SYMLINKS_DIRS = "symlinks/dirs"
16-
PYCHARM_APP_FULL_PATH = "/Applications/PyCharm.app/Contents/MacOS/pycharm"
16+
from clit.constants import SECTION_SYMLINKS_DIRS, SECTION_SYMLINKS_FILES
1717

1818

1919
@click.command()
@@ -109,25 +109,6 @@ def message(text, logger_func=LOGGER.warning):
109109
message("link created", LOGGER.info)
110110

111111

112-
@click.command()
113-
@click.argument("files", nargs=-1)
114-
def pycharm_cli(files):
115-
"""Invoke PyCharm on the command line.
116-
117-
If a file doesn't exist, call `which` to find out the real location.
118-
"""
119-
full_paths = []
120-
for possible_file in files:
121-
if os.path.isfile(possible_file):
122-
real_file = os.path.abspath(possible_file)
123-
else:
124-
real_file = check_output(["which", possible_file]).decode().strip()
125-
full_paths.append(real_file)
126-
command_line = [PYCHARM_APP_FULL_PATH] + full_paths
127-
print(crayons.green("Calling PyCharm with {}".format(" ".join(command_line))))
128-
call(command_line)
129-
130-
131112
def sync_dir(source_dirs: List[str], destination_dirs: List[str], dry_run: bool = False, kill: bool = False):
132113
"""Synchronize a source directory with a destination."""
133114
# Import locally, so we get an error only in this function, and not in other functions of this module.
@@ -168,42 +149,57 @@ def backup_full(ctx, dry_run: bool, kill: bool, pictures: bool):
168149
print(ctx.get_help())
169150

170151

171-
@click.command()
172-
@click.option("--delete", "-d", default=False, is_flag=True, help="Delete pytest directory first")
173-
@click.option("--failed", "-f", default=False, is_flag=True, help="Run only failed tests")
174-
@click.option("--count", "-c", default=0, help="Repeat the same test several times")
175-
@click.option("--reruns", "-r", default=0, help="Re-run a failed test several times")
176-
@click.argument("class_names_or_args", nargs=-1)
177-
def pytest_run(delete: bool, failed: bool, count: int, reruns: int, class_names_or_args: Tuple[str]):
178-
"""Run pytest with some shortcut options."""
179-
# Import locally, so we get an error only in this function, and not in other functions of this module.
180-
from plumbum.cmd import time as time_cmd, rm
181-
182-
if delete:
183-
print(crayons.green("Removing .pytest directory", bold=True))
184-
rm["-rf", ".pytest"] & FG
185-
186-
pytest_plus_args = ["pytest", "-vv", "--run-intermittent"]
187-
if reruns:
188-
pytest_plus_args.extend(["--reruns", str(reruns)])
189-
if failed:
190-
pytest_plus_args.append("--failed")
191-
192-
if count:
193-
pytest_plus_args.extend(["--count", str(count)])
194-
195-
if class_names_or_args:
196-
targets = []
197-
for name in class_names_or_args:
198-
if "." in name:
199-
parts = name.split(".")
200-
targets.append("{}.py::{}".format("/".join(parts[0:-1]), parts[-1]))
201-
else:
202-
# It might be an extra argument, let's just append it
203-
targets.append(name)
204-
pytest_plus_args.append("-s")
205-
pytest_plus_args.extend(targets)
206-
207-
print(crayons.green("Running tests: time {}".format(" ".join(pytest_plus_args)), bold=True))
208-
rv = time_cmd[pytest_plus_args] & RETCODE(FG=True)
209-
exit(rv)
152+
def shell(command_line, quiet=False, return_lines=False, **kwargs):
153+
"""Print and run a shell command."""
154+
if not quiet:
155+
print("$ {}".format(command_line))
156+
if return_lines:
157+
kwargs.setdefault("stdout", PIPE)
158+
159+
completed_process = run(command_line, shell=True, universal_newlines=True, **kwargs)
160+
if not return_lines:
161+
return completed_process
162+
163+
stdout = completed_process.stdout.strip().strip("\n")
164+
return stdout.split("\n") if stdout else []
165+
166+
167+
def shell_find(command_line, **kwargs):
168+
"""Run a find command using the shell, and return its output as a list."""
169+
if not command_line.startswith("find"):
170+
command_line = f"find {command_line}"
171+
kwargs.setdefault("quiet", True)
172+
kwargs.setdefault("check", True)
173+
return shell(command_line, return_lines=True, **kwargs)
174+
175+
176+
def _check_type(full_path, method, msg):
177+
"""Check a path, raise an error if it's not valid."""
178+
obj = Path(full_path)
179+
if not method(obj):
180+
raise ArgumentTypeError(f"{full_path} is not a valid existing {msg}")
181+
return obj
182+
183+
184+
def existing_directory_type(directory):
185+
"""Convert the string to a Path object, raising an error if it's not a directory. Use with argparse."""
186+
return _check_type(directory, Path.is_dir, "directory")
187+
188+
189+
def existing_file_type(file):
190+
"""Convert the string to a Path object, raising an error if it's not a file. Use with argparse."""
191+
return _check_type(file, Path.is_file, "file")
192+
193+
194+
def wait_for_process(process_name: str) -> None:
195+
"""Wait for a process to finish.
196+
197+
https://stackoverflow.com/questions/1058047/wait-for-any-process-to-finish
198+
"""
199+
pid = shell(f"pidof {process_name}", quiet=True, stdout=PIPE).stdout.strip()
200+
if not pid:
201+
return
202+
203+
pid_path = Path(f"/proc/{pid}")
204+
while pid_path.exists():
205+
sleep(0.5)

clit/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Types."""
2+
from typing import Any, Dict
3+
4+
JsonDict = Dict[str, Any]

0 commit comments

Comments
 (0)