generate-systemd-timer 0.1.3__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ ---
2
+ version: 2
3
+
4
+ updates:
5
+ - package-ecosystem: pip
6
+ directory: /
7
+ schedule:
8
+ interval: monthly
9
+ groups:
10
+ python-packages:
11
+ patterns:
12
+ - "*"
13
+ - package-ecosystem: github-actions
14
+ directory: /
15
+ schedule:
16
+ interval: monthly
17
+ groups:
18
+ github-actions:
19
+ patterns:
20
+ - "*"
@@ -0,0 +1,53 @@
1
+ name: Publish tags
2
+ on: push
3
+
4
+ jobs:
5
+ build:
6
+ name: build
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v6
10
+ - name: Install uv
11
+ uses: astral-sh/setup-uv@v6
12
+ with:
13
+ enable-cache: true
14
+ - name: Build and check
15
+ run: |
16
+ uv build
17
+ uvx twine check dist/*
18
+ - name: Upload build artifact
19
+ uses: actions/upload-artifact@v7
20
+ with:
21
+ name: "builds"
22
+ path: "dist/*"
23
+ if-no-files-found: error
24
+ - name: Check tag name if we're to publish
25
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
26
+ run: |
27
+ if ! [[ "v$(uv version --short)" == "${GITHUB_REF##*/}" ]]; then
28
+ echo "Tag does not match!"
29
+ exit 1
30
+ fi
31
+ pypi-publish:
32
+ runs-on: ubuntu-latest
33
+ environment:
34
+ name: pypi
35
+ url: https://pypi.org/p/generate-systemd-timer
36
+ needs: build
37
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
38
+ permissions:
39
+ id-token: write
40
+ contents: write
41
+ steps:
42
+ - name: "Download build artifacts"
43
+ uses: actions/download-artifact@v8
44
+ with:
45
+ name: "builds"
46
+ path: "dist/"
47
+ - name: Create release on Github
48
+ uses: softprops/action-gh-release@v3
49
+ with:
50
+ files: |
51
+ dist/*
52
+ - name: "Publish to pypi"
53
+ uses: pypa/gh-action-pypi-publish@v1.14.0
@@ -0,0 +1,37 @@
1
+ name: Tests
2
+ on:
3
+ push:
4
+ pull_request:
5
+
6
+ jobs:
7
+ lint:
8
+ name: ruff
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v6
12
+ - name: Install uv
13
+ uses: astral-sh/setup-uv@v6
14
+ with:
15
+ enable-cache: true
16
+ - name: Ruff lint
17
+ run: uvx ruff check .
18
+ - name: Ruff format check
19
+ run: uvx ruff format --check .
20
+
21
+ test:
22
+ name: pytest (py${{ matrix.python-version }})
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ fail-fast: false
26
+ matrix:
27
+ # uv provides every interpreter, so 3.8 works on ubuntu-latest too.
28
+ python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
29
+ steps:
30
+ - uses: actions/checkout@v6
31
+ - name: Install uv
32
+ uses: astral-sh/setup-uv@v6
33
+ with:
34
+ enable-cache: true
35
+ python-version: ${{ matrix.python-version }}
36
+ - name: Run pytest
37
+ run: uv run --frozen pytest
@@ -0,0 +1,143 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ pip-wheel-metadata/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ #Pipfile.lock
97
+
98
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
99
+ __pypackages__/
100
+
101
+ # Celery stuff
102
+ celerybeat-schedule
103
+ celerybeat.pid
104
+
105
+ # SageMath parsed files
106
+ *.sage.py
107
+
108
+ # Environments
109
+ .env
110
+ .venv
111
+ env/
112
+ venv/
113
+ ENV/
114
+ env.bak/
115
+ venv.bak/
116
+
117
+ # Spyder project settings
118
+ .spyderproject
119
+ .spyproject
120
+
121
+ # Rope project settings
122
+ .ropeproject
123
+
124
+ # mkdocs documentation
125
+ /site
126
+
127
+ # mypy
128
+ .mypy_cache/
129
+ .dmypy.json
130
+ dmypy.json
131
+
132
+ # Pyre type checker
133
+ .pyre/
134
+
135
+ # pytype static type analyzer
136
+ .pytype/
137
+
138
+ # Cython debug symbols
139
+ cython_debug/
140
+
141
+ # static files generated from Django application using `collectstatic`
142
+ media
143
+ static
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: generate-systemd-timer
3
+ Version: 2.0.0
4
+ Summary: Generate a systemd unit.timer and unit.service pair
5
+ Project-URL: Repository, https://github.com/thomwiggers/systemd-timer-generator
6
+ Author-email: Thom Wiggers <thom@thomwiggers.nl>
7
+ License-Expression: MIT
8
+ Requires-Python: >=3.8
9
+ Requires-Dist: jinja2<4,>=3
10
+ Requires-Dist: python-editor<2,>=1.0
11
+ Requires-Dist: setuptools<76.0,>=70.3
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Systemd generator for timer units
15
+
16
+ Generates systemd `.timer` and `.service` units to more easily add cron-like tasks to your system.
17
+
18
+ After editing the units, the tool can install them for you: copy them into
19
+ `/etc/systemd/system` or `$HOME/.config/systemd/user` (creating the directory if
20
+ needed), run `systemctl daemon-reload`, and enable and start the timer.
21
+
22
+ ## Usage
23
+
24
+ ```sh
25
+ generate-systemd-timer unit-name
26
+ # Now two editors will pop up to allow you to customize
27
+ # Afterwards you'll find unit-name.service and unit-name.timer in the current folder.
28
+ # Finally, you'll be asked whether to install, reload and enable the timer.
29
+ ```
@@ -2,8 +2,9 @@
2
2
 
3
3
  Generates systemd `.timer` and `.service` units to more easily add cron-like tasks to your system.
4
4
 
5
- You'll still have to copy them into the right place (either `/etc/systemd/system`
6
- or `$HOME/.config/systemd/user`) and reload systemd using `systemctl daemon-reload`.
5
+ After editing the units, the tool can install them for you: copy them into
6
+ `/etc/systemd/system` or `$HOME/.config/systemd/user` (creating the directory if
7
+ needed), run `systemctl daemon-reload`, and enable and start the timer.
7
8
 
8
9
  ## Usage
9
10
 
@@ -11,4 +12,5 @@ or `$HOME/.config/systemd/user`) and reload systemd using `systemctl daemon-relo
11
12
  generate-systemd-timer unit-name
12
13
  # Now two editors will pop up to allow you to customize
13
14
  # Afterwards you'll find unit-name.service and unit-name.timer in the current folder.
15
+ # Finally, you'll be asked whether to install, reload and enable the timer.
14
16
  ```
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "generate-systemd-timer"
3
+ version = "2.0.0"
4
+ description = "Generate a systemd unit.timer and unit.service pair"
5
+ license = "MIT"
6
+ readme = "README.md"
7
+ requires-python = ">=3.8"
8
+ authors = [
9
+ { name = "Thom Wiggers", email = "thom@thomwiggers.nl" },
10
+ ]
11
+ dependencies = [
12
+ "jinja2>=3,<4",
13
+ "python-editor>=1.0,<2",
14
+ "setuptools>=70.3,<76.0",
15
+ ]
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/thomwiggers/systemd-timer-generator"
19
+
20
+ [project.scripts]
21
+ generate-systemd-timer = "systemd_generator:main"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["systemd_generator"]
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest>=8,<9",
33
+ ]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ addopts = "-ra"
38
+
39
+ [tool.ruff]
40
+ target-version = "py38"
41
+
42
+ [tool.ruff.lint]
43
+ select = [
44
+ "E", # pycodestyle errors
45
+ "F", # pyflakes
46
+ "I", # isort
47
+ "UP", # pyupgrade
48
+ "B", # flake8-bugbear
49
+ ]
@@ -0,0 +1,197 @@
1
+ """Generate systemd timer files"""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+
7
+ import editor
8
+ import jinja2
9
+
10
+ USER_UNIT_DIR = os.path.expanduser("~/.config/systemd/user")
11
+ SYSTEM_UNIT_DIR = "/etc/systemd/system"
12
+
13
+
14
+ TEMPLATE_TIMER = """\
15
+ [Unit]
16
+ Description=Generated timer for {{ service_name }} by {{ script_name }}
17
+
18
+ [Timer]
19
+ #Unit={{ service_name }}.service
20
+
21
+ # Select one of the following
22
+ # OnCalendar: daily / weekly / monthly
23
+ #OnCalendar=
24
+ # OnActiveSec: Time after this timer has been loaded
25
+ #OnActiveSec=
26
+ # OnBootSec: Time relative to boot
27
+ #OnBootSec=
28
+ # OnStartupSec: Time relative to systemd manager start; relevant for user login
29
+ #OnStartupSec=
30
+ # OnUnitActiveSec: Defines it relative to when the to-be-started unit was last activated
31
+ #OnUnitActiveSec=
32
+ # OnUnitInactiveSec: Defines it relative to when the to-be-started unit
33
+ # was last deactivated
34
+ #OnUnitInactiveSec=
35
+
36
+ # Optional settings
37
+ # AccuracySec: Defines the accuracy with which this timer shall elapse
38
+ #AccuracySec=1m
39
+ # RandomizedDelaySec: Defines a randomized delay to be added to the start time
40
+ #RandomizedDelaySec=0
41
+ # Persistent: Also activate if the timer expired while timer was inactive
42
+ # Only relevant for OnCalendar
43
+ #Persistent=false
44
+
45
+ # Probably not necessary
46
+ # OnClockChange: Activate this unit whenever the clock jumps
47
+ #OnClockChange=false
48
+ # OnTimezoneChange: Activate whenever the timezone changes
49
+ #OnTimezoneChange=false
50
+ # WakeSystem: Resume the system from suspend to activate (if supported)
51
+ #WakeSystem=false
52
+ # RemainAfterElapse: Keeps the timer in the service manager once elapsed.
53
+ #RemainAfterElapse=true
54
+
55
+ [Install]
56
+ WantedBy=timers.target
57
+
58
+ # vim : set ft=systemd.timer :
59
+ """
60
+
61
+ TEMPLATE_SERVICE = """\
62
+ [Unit]
63
+ Description=Generated service for {{ service_name }} by {{ script_name }}
64
+
65
+ [Service]
66
+ Type=oneshot
67
+ ExecStart=/bin/true
68
+
69
+ [Install]
70
+ WantedBy=multi-user.target
71
+
72
+ # See https://www.freedesktop.org/software/systemd/man/systemd.service.html#Examples
73
+
74
+ # vim : set ft=systemd.service :
75
+ """
76
+
77
+
78
+ def _get_jinja_environment():
79
+ env = jinja2.Environment()
80
+ return env
81
+
82
+
83
+ def _render_timer(service_name):
84
+ """Render the systemd.template file"""
85
+ script_name = sys.argv[0]
86
+ template = _get_jinja_environment().from_string(TEMPLATE_TIMER)
87
+ return template.render(
88
+ service_name=service_name,
89
+ script_name=script_name,
90
+ )
91
+
92
+
93
+ def _render_service(service_name):
94
+ """Render the systemd.service file"""
95
+ script_name = sys.argv[0]
96
+ template = _get_jinja_environment().from_string(TEMPLATE_SERVICE)
97
+ return template.render(
98
+ service_name=service_name,
99
+ script_name=script_name,
100
+ )
101
+
102
+
103
+ def _prompt_yes_no(question, default=False):
104
+ """Ask a yes/no question on stdin. Returns the default if non-interactive."""
105
+ if not sys.stdin.isatty():
106
+ return default
107
+ options = "[Y/n]" if default else "[y/N]"
108
+ try:
109
+ answer = input(f"{question} {options} ").strip().lower()
110
+ except EOFError:
111
+ return default
112
+ if not answer:
113
+ return default
114
+ return answer in ("y", "yes")
115
+
116
+
117
+ def _run(cmd):
118
+ """Echo and run a command, returning its exit code."""
119
+ print(f"+ {' '.join(cmd)}")
120
+ return subprocess.call(cmd)
121
+
122
+
123
+ def _manual_instructions(service_name, target):
124
+ print(
125
+ "\nTo install manually:\n"
126
+ f" cp {service_name}.service {service_name}.timer {target}/\n"
127
+ f" systemctl daemon-reload\n"
128
+ f" systemctl enable --now {service_name}.timer"
129
+ )
130
+
131
+
132
+ def _install(service_name):
133
+ """Offer to copy the units into place, reload systemd and enable the timer."""
134
+ units = [f"{service_name}.service", f"{service_name}.timer"]
135
+
136
+ user_scope = _prompt_yes_no(
137
+ "Install as a user unit (instead of system-wide)?", default=True
138
+ )
139
+ if user_scope:
140
+ target = USER_UNIT_DIR
141
+ sudo = []
142
+ systemctl = ["systemctl", "--user"]
143
+ else:
144
+ target = SYSTEM_UNIT_DIR
145
+ sudo = ["sudo"]
146
+ systemctl = ["sudo", "systemctl"]
147
+
148
+ if not _prompt_yes_no(f"Copy units into {target} now?", default=True):
149
+ _manual_instructions(service_name, target)
150
+ return
151
+
152
+ if not os.path.isdir(target):
153
+ if _prompt_yes_no(
154
+ f"Directory {target} does not exist. Create it?", default=True
155
+ ):
156
+ if _run(sudo + ["mkdir", "-p", target]) != 0:
157
+ print("Failed to create directory; aborting install.", file=sys.stderr)
158
+ return
159
+ else:
160
+ _manual_instructions(service_name, target)
161
+ return
162
+
163
+ for unit in units:
164
+ if _run(sudo + ["cp", unit, os.path.join(target, unit)]) != 0:
165
+ print(f"Failed to copy {unit}; aborting install.", file=sys.stderr)
166
+ return
167
+
168
+ _run(systemctl + ["daemon-reload"])
169
+
170
+ if _prompt_yes_no(f"Enable and start {service_name}.timer now?", default=True):
171
+ _run(systemctl + ["enable", "--now", f"{service_name}.timer"])
172
+
173
+
174
+ def main():
175
+ if len(sys.argv) != 2:
176
+ print(f"Usage: {sys.argv[0]} <service name>", file=sys.stderr)
177
+ sys.exit(1)
178
+
179
+ service_name = sys.argv[1]
180
+
181
+ with open(f"{service_name}.service", "w") as f:
182
+ f.write(_render_service(service_name))
183
+ editor.edit(filename=f"{service_name}.service")
184
+ with open(f"{service_name}.timer", "w") as f:
185
+ f.write(_render_timer(service_name))
186
+ editor.edit(filename=f"{service_name}.timer")
187
+
188
+ if _prompt_yes_no(
189
+ f"\nGenerated {service_name}.service and {service_name}.timer.\n"
190
+ "Install them now?",
191
+ default=False,
192
+ ):
193
+ _install(service_name)
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()