From 5f429d73eaaab8f37664179348024f48c546b045 Mon Sep 17 00:00:00 2001 From: Kumi Date: Thu, 2 May 2024 14:49:06 +0200 Subject: [PATCH] feat: Initialize hostsd project with PyPI CI/CD Introduced a comprehensive setup for `hostsd`, a simple hosts file manager, marking the project's inception. This entails crafting the PyPI CI/CD pipeline configuration, ensuring seamless publication processes on tag-based releases. Additionally, standard project necessities such as `.gitignore`, `LICENSE`, and `README.md` were put in place, alongside the core project configuration in `pyproject.toml`. The initial source code foundation is laid out in `src/hostsd`, featuring argument parsing and file management logic essential for hosts file manipulation. The PyPI CI/CD configuration in `.forgejo/workflows/pypi.yml` facilitates automated publishing to PyPI upon tagging, backed by Docker to guarantee environment consistency. The project adopts a community-friendly stance with an MIT license, encouraging open collaboration. Documentation is immediately available via `README.md`, covering installation, usage, and automated update setups, ensuring users can effectively leverage `hostsd` from the get-go. This setup underscores the project's commitment to enabling efficient hosts file management across various environments, laying the groundwork for future enhancements and community contributions. --- .forgejo/workflows/pypi.yml | 32 +++++++++ .gitignore | 5 ++ LICENSE | 19 ++++++ README.md | 82 +++++++++++++++++++++++ pyproject.toml | 27 ++++++++ src/hostsd/__init__.py | 0 src/hostsd/__main__.py | 126 ++++++++++++++++++++++++++++++++++++ 7 files changed, 291 insertions(+) create mode 100644 .forgejo/workflows/pypi.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/hostsd/__init__.py create mode 100644 src/hostsd/__main__.py diff --git a/.forgejo/workflows/pypi.yml b/.forgejo/workflows/pypi.yml new file mode 100644 index 0000000..98badde --- /dev/null +++ b/.forgejo/workflows/pypi.yml @@ -0,0 +1,32 @@ +name: Python Package CI/CD + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + publish: + name: Publish to PyPI + container: + image: node:20-bookworm + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + apt update + apt install -y python3 python3-venv + + - name: Publish to PyPI + run: | + python3 -m venv venv + . ./venv/bin/activate + pip install -U hatchling twine build + python -m build . + python -m twine upload --username __token__ --password ${PYPI_TOKEN} dist/* + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b18bf76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +dist/ +venv/ +*.pyc +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..27a3fbf --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Kumi Mitterer + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f2b7d1 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# hostsd - A simple hosts file manager + +[![Support Private.coffee!](https://shields.private.coffee/badge/private.coffee-support%20us!-pink?logo=coffeescript)](https://private.coffee) +[![PyPI](https://shields.private.coffee/pypi/v/hostsd)](https://pypi.org/project/hostsd/) +[![PyPI - Python Version](https://shields.private.coffee/pypi/pyversions/hostsd)](https://pypi.org/project/hostsd/) +[![PyPI - License](https://shields.private.coffee/pypi/l/hostsd)](https://pypi.org/project/hostsd/) +[![Git Workflow Status](https://shields.private.coffee/gitea/last-commit/kumi/hostsd?gitea-url=https://git.private.coffee)](https://git.private.coffee/kumi/hostsd) + +`hostsd` is a simple hosts file manager that allows you to separate your hosts file into multiple files and easily enable or disable them. It's useful for development environments where you need to manage lots of hosts entries, or for managing ad-blocking hosts files. + +## Dependencies + +- Python 3.8 or later (earlier versions may work but are untested) +- Linux or macOS (should work on Windows too but is untested) + +## Installation + +```bash +pip install hostsd +``` + +## Usage + +To write the contents of `/etc/hosts.d/*` to `/etc/hosts`: + +```bash +sudo hostsd +``` + +You can also specify the input and output paths: + +```bash +hostsd -i /etc/hosts.d -o /etc/hosts +``` + +You can disable a file by adding a `.disabled` extension: + +```bash +mv /etc/hosts.d/10-my-file /etc/hosts.d/10-my-file.disabled +``` + +Hidden files (files starting with a dot) are ignored as well. + +## Running hostsd automatically + +You can run `hostsd` automatically using a cron job or a systemd timer. Here's an example of a systemd timer (assuming you have installed `hostsd` globally, for simplicity): + +```ini +# /etc/systemd/system/hostsd.timer +[Unit] +Description=Update hosts file every minute + +[Timer] +OnBootSec=1min +OnUnitActiveSec=1min + +[Install] +WantedBy=timers.target +``` + +```ini +# /etc/systemd/system/hostsd.service +[Unit] +Description=Update hosts file + +[Service] +Type=oneshot +ExecStart=/usr/bin/hostsd +``` + +```bash +sudo systemctl enable hostsd.timer +sudo systemctl start hostsd.timer +``` + +This will run `hostsd` every minute. So you can just drop a new file in `/etc/hosts.d` and it will be picked up automatically. + +You could even combine this with a git repository and you have a simple way to manage your hosts files across multiple machines or share them with others, without needing to set up and manage a full DNS server. + +## License + +hostsd is licensed under the MIT license. See [LICENSE](LICENSE) for the full license text. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..57858f2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hostsd" +version = "0.1.0" +authors = [ + { name="Kumi Mitterer", email="hostsd@kumi.email" }, +] +description = "A simple hosts file manager" +readme = "README.md" +license = { file="LICENSE" } +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.scripts] +hostsd = "hostsd.__main__:main" + +[project.urls] +"Homepage" = "https://git.private.coffee/kumi/hostsd" +"Bug Tracker" = "https://git.private.coffee/kumi/hostsd/issues" +"Source Code" = "https://git.private.coffee/kumi/hostsd" \ No newline at end of file diff --git a/src/hostsd/__init__.py b/src/hostsd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hostsd/__main__.py b/src/hostsd/__main__.py new file mode 100644 index 0000000..a02556d --- /dev/null +++ b/src/hostsd/__main__.py @@ -0,0 +1,126 @@ +from pathlib import Path +from os import PathLike +from typing import Union +from argparse import ArgumentParser +from pathlib import Path + +import sys + +HOSTS_DIR = "/etc/hosts.d/" +HOSTS_FILE = "/etc/hosts" + +HOSTS_DIR_WINDOWS = "C:\\Windows\\System32\\drivers\\etc\\hosts.d\\" +HOSTS_FILE_WINDOWS = "C:\\Windows\\System32\\drivers\\etc\\hosts" + + +def get_new_content(dirpath: Union[str, PathLike] = HOSTS_DIR) -> str: + """Read all files from a directory and join their contents in a string + + This will not read files with names that begin with a period ("."), or binary files. + + Args: + dir (str, optional): The directory from which to read files. Defaults to "/etc/hosts.d/". + + Raises: + IOError: Raised if the provided source path does not exist or is not a directory. + + Returns: + str: Joined content of read files + """ + content: str = "" + + directory = Path(dirpath) + + if not (directory.exists() and directory.is_dir()): + raise IOError(f"Directory {dirpath} does not exist or is not a directory.") + + for infile in sorted(list(directory.iterdir()), key=lambda f: f.name): + if ( + infile.is_file() + and not infile.name.startswith(".") + and not infile.name.endswith(".disabled") + ): + with infile.open('r') as openfile: + try: + filecontent = openfile.read() + except UnicodeDecodeError: + print(f"File {infile.name} is not a text file - skipping") + + if content: + content += "\n\n" + + content += f"# {infile.name}\n\n{filecontent}" + + return content + + +def write_hosts_file(content: str, path: Union[str, PathLike] = HOSTS_FILE): + """Simple function writing text content to a file given by path + + Args: + content (str): Text content to be written. + path (Union[str, PathLike], optional): Path of the file to write to. Defaults to "/etc/hosts". + + Raises: + IOError: Raised if the provided file path could not be written to. + """ + with Path(path).open('w') as hostsfile: + try: + hostsfile.write(content) + except Exception as e: + raise IOError(f"Unable to write file {path} – {e}") + + +def main(): + parser = ArgumentParser( + description="Update /etc/hosts file with contents of /etc/hosts.d/ directory" + ) + + parser.add_argument( + "-d", + "--dir", + help=f"Directory containing the hosts files to be merged (default: {HOSTS_DIR})", + default=HOSTS_DIR, + ) + + parser.add_argument( + "-o", + "--output", + help=f"File to write the merged content to (default: {HOSTS_FILE})", + default=HOSTS_FILE, + ) + + args = parser.parse_args() + + if args.dir: + input_dir = Path(args.dir) + else: + if sys.platform == "win32": + input_dir = Path(HOSTS_DIR_WINDOWS) + else: + input_dir = Path(HOSTS_DIR) + + if not input_dir.exists(): + print(f"Directory {args.dir} does not exist – exiting") + return + + if args.output: + output_file = Path(args.output) + else: + if sys.platform == "win32": + output_file = Path(HOSTS_FILE_WINDOWS) + else: + output_file = Path(HOSTS_FILE) + + if not output_file.exists(): + print(f"File {args.output} does not exist – exiting") + return + + try: + write_hosts_file(get_new_content(args.dir), args.output) + except IOError as e: + print(f"An error occurred: {e}") + + +if __name__ == "__main__": + main()