1
0
Fork 0
mirror of synced 2024-11-04 08:28:56 -05:00

Compare commits

..

247 commits

Author SHA1 Message Date
Anish Athalye
720206578a Update CI setup
macos-latest runs on arm64, and Python 3.6 / Python 3.7 are not
available for that architecture.
2024-05-11 13:08:17 -04:00
Anish Athalye
ba1c0f0e9a Rename workflow file 2024-05-11 13:05:42 -04:00
Anish Athalye
3f9e409669 Merge branch 'kurtmckee/support-python-3.12' 2023-09-10 12:08:56 -04:00
Kurt McKee
26720b8264
Add Python 3.12 to the test suite; mark it as supported 2023-09-10 10:34:20 -05:00
Kurt McKee
9bb82306f4
Configure pytest to escalate warnings to errors during testing 2023-09-10 10:28:14 -05:00
Kurt McKee
eba0b24331
Resolve a Python 3.12 DeprecationWarning 2023-09-10 10:27:54 -05:00
Anish Athalye
840cd164d2 Release 1.20.1 2023-09-10 10:37:02 -04:00
Anish Athalye
cd1ff70113 Merge branch 'bennyyip/master' 2023-09-10 10:36:49 -04:00
bennyyip
390d306284 Fix wrong pyyaml path 2023-09-10 22:29:44 +08:00
Anish Athalye
7a24ded5a6 Release 1.20.0 2023-09-09 21:03:40 -04:00
Anish Athalye
b7e54a9bb3 Add badges 2023-09-09 21:03:11 -04:00
Anish Athalye
eace76f697 Add Python 3.11 testing to CI 2023-09-09 20:57:01 -04:00
Anish Athalye
b732baf163 Upgrade PyYAML to 6.0.1
This was causing install issues with newer versions of Python /
setuptools; see https://github.com/yaml/pyyaml/issues/723.

Thanks to Rui Chen <rui@chenrui.dev> for reporting this issue in
https://github.com/anishathalye/dotbot/pull/340.
2023-09-09 20:57:01 -04:00
Anish Athalye
81d4a434ef Drop support for Python 2 and Python < 3.6
This patch also deletes all of the compatibility code that we no longer
need.
2023-09-09 20:57:01 -04:00
Anish Athalye
712b30a445 Add Python 2.7 testing back to CI
This patch uses the strategy described in
https://github.com/actions/setup-python/issues/672.
2023-07-16 12:10:33 -04:00
Anish Athalye
b04a3f1844 Release 1.19.2 2023-07-09 16:28:06 -04:00
Anish Athalye
416f32f5fe Fix globbing when there is a single match
See https://github.com/anishathalye/dotbot/issues/282 and
https://github.com/anishathalye/dotbot/issues/315.

This patch simplifies the implementation, removing special-case handling
for the cases of zero matches and one match. Instead, any situation
where `glob: true` is specified and the path contains a glob character
(any of "?", "*", or "[") is treated as a glob case. The reason we check
both `use_glob` and `_has_glob_chars()` is to more gracefully handle the
case where the user has enabled globs by default, but most links do not
contain glob characters and should not be treated as globs.
2023-07-09 16:24:13 -04:00
Anish Athalye
9f8fd76f32 Add note about exclude path interpretation
See https://github.com/anishathalye/dotbot/issues/292.
2023-07-09 16:20:27 -04:00
Anish Athalye
4daa065dc9 Allow empty glob
This is convenient and more flexible. See
https://github.com/anishathalye/dotbot/issues/284.
2023-07-09 16:20:27 -04:00
Anish Athalye
ed60c62432 Improve output of '--version' 2023-07-09 15:26:46 -04:00
Anish Athalye
53b3781fbb Drop Python 2.7 from CI
The setup-python action no longer supports Python 2.7:
https://github.com/actions/setup-python/issues/672.
2023-06-25 11:16:14 -04:00
Anish Athalye
328bcb3259 Remove copyright years 2023-03-05 07:37:27 -05:00
Anish Athalye
da928a4c6b Release 1.19.1 2022-12-17 15:13:48 -05:00
Anish Athalye
1d56409bc1 Add note about Windows support 2022-12-17 15:12:43 -05:00
Anish Athalye
8468213bc6 Add code coverage for all platforms 2022-12-17 15:05:04 -05:00
Anish Athalye
e810f42ca2 Deduplicate format checking
This was being checked in both the tox tests and separately in GitHub
actions.
2022-12-17 14:10:17 -05:00
Anish Athalye
593584154d Add instructions on how to run tests in Docker 2022-12-17 14:05:39 -05:00
Anish Athalye
3965e1a390 Merge branch 'kurtmckee/test-on-windows-issue-309' 2022-12-17 14:01:22 -05:00
Kurt McKee
e0c78d9c56
Resolve merge conflicts
Note that this does NOT port the following command over:

```shell
git config --global protocol.file.allow always
```

Doing so would change the git configuration of users running
the unit tests locally, and this is not an acceptable outcome.
Instead, the git configuration is modified at the CLI using
the `-c protocol.file.allow=always` argument to accomplish
the same thing without side effects.
2022-12-16 13:52:33 -06:00
Anish Athalye
860a56580b Fix CI 2022-12-11 10:58:48 -05:00
Anish Athalye
9593510bd4 Merge branch 's1as3r/master' 2022-11-17 13:26:55 -05:00
Anish Athalye
5d34d005e8 Fix test 2022-11-17 13:26:32 -05:00
Arbaaz Shafiq
76ccc53eee Don't show command if quiet: true
Don't print the command if quiet option is set to true and no
description is given.
2022-11-17 13:23:23 -05:00
Anish Athalye
1971ae9f51 Add workaround for test broken by Git security fix
The fix for this Git security issue [1] involved disabling the local
clone optimization when the repository contains symbolic links. The
security fix broke this particular test; this patch works around the
fix.

[1]: https://github.com/git/git/security/advisories/GHSA-3wp6-j8xr-qw85
2022-11-17 10:27:21 -05:00
Kurt McKee
d12aa83673 Document how to run the unit tests locally
When verifying the steps on Windows, the `.eggs` directory suddenly appeared.
This is now ignored.
2022-05-18 07:15:59 -05:00
Kurt McKee
7a586aa4c5 Remove the Vagrant-based tests 2022-05-18 07:01:06 -05:00
Kurt McKee
d055802a66 Fix pypy3 CI issue on MacOS 2022-05-18 06:37:48 -05:00
Kurt McKee
59b1b85d07 Account for MacOS and Windows temp directory issues 2022-05-18 06:37:48 -05:00
Kurt McKee
ee3646bba3 Update CI to use tox tests 2022-05-13 10:44:29 -05:00
Kurt McKee
57a27a770c Add code coverage reports 2022-05-13 10:44:29 -05:00
Kurt McKee
5c0ddc6fc1 Migrate the bin/dotbot script test to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
30f310e935 Remove Python 2 references in the Powershell shim
CPython >= 3.8 is required for proper Windows support.
2022-05-13 10:44:29 -05:00
Kurt McKee
74aca02157 Migrate the shim test to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
1ff796a9dc Enforce platform-specific CPython version requirements for Windows in tox
This also changes the black and isort tests to use CPython 3.9
because Cygwin currently doesn't have CPython 3.10 available.
2022-05-13 10:44:29 -05:00
Kurt McKee
ea98e5eafc Add isort as a tox environment, and run it 2022-05-13 10:44:29 -05:00
Kurt McKee
566ba0b853 Add black as tox environment, and run it 2022-05-13 10:44:29 -05:00
Kurt McKee
b5499c7dc5 Separate module importing from plugin identification
This change allows the test framework to reliably specify
which plugins to load and use within the same process.

Previously, plugins were loaded by importing files and then
accessing the Plugin class' list of subclasses.
Now, it's possible to run dotbot multiple times without
plugins accruing across runs with different configurations
and CLI arguments.

In addition, this fixes some circular imports that were
previously avoided because plugins were imported in a function.
2022-05-13 10:44:29 -05:00
Kurt McKee
a8dd89f48f Migrate CLI argument tests to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
68246ba33e Migrate shell-* tests to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
b8dfbae730 Migrate config-* tests to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
a2846d0a61 Resolve Windows-specific clean issues 2022-05-13 10:44:29 -05:00
Kurt McKee
5b7db08e8a Migrate clean-* tests to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
5d11c7954d Resolve Windows-specific create issues 2022-05-13 10:44:29 -05:00
Kurt McKee
b59b3af448 Migrate create-* tests to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
78bec43e33 Resolve Windows-specific link issues 2022-05-13 10:44:29 -05:00
Kurt McKee
4469b857aa Migrate link-* tests to Python 2022-05-13 10:44:29 -05:00
Kurt McKee
c015f7bce8 Add a test framework for all supported Python versions 2022-05-13 10:44:29 -05:00
Anish Athalye
d2f76a2593 Merge branch 'kurtmckee/update-vm-os' 2022-04-11 08:05:50 -04:00
Anish Athalye
5f84611372 Merge branch 'kurtmckee/fix-vagrant-unauthenti...' 2022-04-11 08:04:45 -04:00
Kurt McKee
57c7c513b9 Update the Vagrantfile target OS to Ubuntu jammy
This resolves VirtualBox 5.x/6.x Guest Additions incompatibilities
that cause all of the unit tests to fail under VirtualBox 6.1.

Fixes #305
2022-04-10 22:24:19 -05:00
Kurt McKee
1931c9ac06 Update the unauthenticated git protocol to HTTPS
The unauthenticated git protocol is no longer supported.

https://github.blog/2021-09-01-improving-git-protocol-security-github/

Fixes #303
2022-04-10 21:11:07 -05:00
Anish Athalye
769767c129 Migrate code style to Black 2022-01-30 18:53:05 -05:00
Anish Athalye
ac5793ceb5 Merge branch 'dkm/patch-1' 2021-10-25 14:39:24 -04:00
Marc Poulhiès
b306a25e13
Update README.md
small typo fix
2021-10-25 20:19:41 +02:00
Anish Athalye
fb18c9b112 Merge branch 'ssbanerje/master' 2021-10-03 20:37:24 -04:00
Subho Banerjee
aaf5037725
better test case for exit-on-failure 2021-09-12 19:40:37 -05:00
Subho Banerjee
4d2e1537f4
adding a exit on failure flag 2021-09-12 19:34:44 -05:00
Anish Athalye
076939da8e Remove stray asterisk 2021-06-02 20:23:55 -04:00
Anish Athalye
24f88c4584 Merge branch 'eengstrom/259-link-prefix' 2021-06-02 20:21:40 -04:00
Anish Athalye
74822af9f5 Merge branch 'eengstrom/270-recursive-globbing' 2021-06-02 20:11:25 -04:00
Anish Athalye
67a3527f67 Merge branch 'eengstrom/280-fix-pyenv-config' 2021-06-02 19:48:19 -04:00
Eric Engstrom
cb6718ba87 test: fix pyenv configuration in vagrant
- addresses change in `pyenv init -` that no longer adjusts PATH.
- also updated testing README.md for readability.

FIXES: #280
2021-06-02 19:47:57 -04:00
Eric Engstrom
6c044208fa
feat: Add prefix: 'string' option to linking when glob: true.
Allows one to store files in a directory or git-repo without the leading 
`.`, as in:

```
dotconf:
├── README.md
├── bin
│   ├── dotbot
│   ├── look
│   ├── pbfile
│   └── ...
├── dot
│   ├── bashrc
│   ├── gitconfig
│   ├── gitignore
│   ├── gorc
│   ├── login
│   ├── ...
│   ├── zshrc
│   └── zshenv
```

Can take a many-line dotbot.yml listing **each** file in `dotconf/dot`,
reducing it to five lines:

```
- link:
    ~/:
      path: dotconf/dot/*
      glob: true
      prefix: '.'
```

FIXES: #259
2021-05-27 12:07:17 -05:00
Eric Engstrom
ab7cbd42dc
feat: Support recursive globbing with **.
For example, will handle an entire directory tree of files, linking all 
files:

```
- link:
    ~/.config/:
      path: dotconf/config/**
      glob: true
```

NOTE, this feature requires newer versions of `glob()` (Python >= 3.5),
and `dotbot` will throw an error if using an earlier version of python.

For testing purposes, added:
  - ability to skip tests in test harness
  - added testing for older Python(s).

FIXES: #270
2021-05-27 11:58:55 -05:00
Anish Athalye
aa9335089b Migrate changelog from wiki into source tree 2021-04-29 18:24:51 -04:00
Anish Athalye
e0cf5f993f Merge branch 'eengstrom/244-fix-link-glob-patterns' 2021-04-29 18:09:39 -04:00
Anish Athalye
289c724b1c Merge branch 'Justintime50/readme-being-typo' 2021-04-29 08:07:06 -04:00
Justintime50
84a836ea33 Fixes typos in README "being" to "begin" 2021-04-28 16:30:48 -06:00
Eric Engstrom
f56e903cee
Support both [] and ? glob patterns 2021-04-15 23:28:19 -05:00
Anish Athalye
dcb3743115 Release 1.19.0 2021-04-06 10:54:27 -04:00
Anish Athalye
0f5a5f3385 Update dates 2021-03-31 20:11:24 -04:00
Anish Athalye
472223300f Drop CI support for Python 3.4 and add Python 3.9 2021-03-13 06:22:58 -05:00
Anish Athalye
eb7f3fb7b1 Include git hash in version when available 2021-02-27 14:34:52 -05:00
Anish Athalye
5f849ad07f Merge branch 'sitiom/master' 2021-02-25 15:05:02 -05:00
sitiom
6de5cc6c2c Add PowerShell install script 2021-02-25 14:43:49 -05:00
Anish Athalye
f15293b3d5 Rename 'canonicalize-path' into 'canonicalize'
This parallels 'relative' (it's not 'relative-path'). The old
'canonicalize-path' is still supported for backward compatibility.
2021-02-25 08:26:19 -05:00
Anish Athalye
66489f7955 Fix missing endings of heredocs 2021-02-25 08:16:27 -05:00
Anish Athalye
dac7a9bc88 Add --debug flag to test driver
This is easier than the old method of adding `DEBUG=true` to the top of
test files.
2021-02-25 08:14:34 -05:00
Anish Athalye
43b62ed532 Merge branch 'etkeys/feature/cli-force-shell-out-err-true' 2021-02-25 08:02:13 -05:00
E. Keys
c35382c06d Add cli option force shell show stderr/stdout
Passing `--verbose` flag two times will now force shell commands to show
stderr/stdout output regardless of settings in config file.

Resolves #104
2021-02-25 08:00:07 -05:00
Anish Athalye
5f1e33ed67 Merge branch 'etkeys/feature/expose-cli-options-for-plugins' 2021-02-25 07:57:19 -05:00
E. Keys
b18ba4d392 Make parsed CLI options available to plugins 2021-02-25 07:57:12 -05:00
Anish Athalye
22ed23c7d9 Merge branch 'etkeys/feature/glob-with-exclude' 2021-02-25 06:59:22 -05:00
Anish Athalye
de8793de99 Merge branch 'etkeys/bugfix/fix-glob-dropping-leading-dot' 2021-02-25 06:44:32 -05:00
E. Keys
6c6fee697e Improve globbing behavior with leading '.' 2021-02-25 06:44:14 -05:00
Anish Athalye
945c1e5a4e Add mode option to create directive
See <https://github.com/anishathalye/dotbot/issues/260>. Thanks to
@eengstrom for the feature suggestion.
2021-02-25 06:25:17 -05:00
Anish Athalye
4ec846cdad Make tests run on pull requests 2020-12-26 05:01:29 -05:00
Anish Athalye
d55e2af83a Add direct link to tutorials page in the wiki 2020-12-23 18:26:56 -05:00
Anish Athalye
be539e6e24 Remove packaging information from README
This content was not relevant to anyone but the owner of the PyPI
package.
2020-12-23 18:08:48 -05:00
Anish Athalye
a5e1dca91d Release 1.18.0 2020-12-23 18:04:23 -05:00
Anish Athalye
d7629553bd Switch to GitHub Actions 2020-12-18 15:34:15 -05:00
Anish Athalye
cf366bbf66 Fix interaction between --only and defaults
We should treat defaults specially, and even when `--only` is
specified, we should always run defaults.
2020-11-22 16:58:00 -05:00
E. Keys
2ec7a24129 Add exclude parameter for link globbing
- Added `exclude` parameter to _link_. Now, an array of glob patterns
    can be given that will be used to remove items from a glob match.
    This parameter will only have an effect when `glob` is `true`.
- Updated README to add description for `exclude` and add in examples.

Resolves #247
2020-11-22 14:13:11 -05:00
Anish Athalye
5294594f5a Merge branch 'bobwhitelock/add-force-color-option' into master 2020-08-23 09:16:58 -04:00
Bob Whitelock
2432a2ba87 Add --force-color option
This forces Dotbot to produce colored output, regardless of whether it
is outputting to a TTY.

This is useful to support use cases such as piping colored Dotbot output
into another program for formatting (e.g. I want to indent the output as
part of a larger installation script); this was not previously easy to
do as this would cause the output to lose its colored formatting.

This option cannot be provided at the same time as the existing
`--no-color` option, as there's no logical interpretation of what effect
providing both of these should have.

As part of this change I've refactored some existing code determining
whether output should be colored to where options are parsed, as this
made this change simpler and I think it makes sense for all this logic
to be performed in the same place.
2020-08-23 00:02:26 +01:00
Anish Athalye
1d4f4348bb Release 1.17.1 2020-07-24 08:42:12 -04:00
Anish Athalye
98b87c16be Merge branch 'TimPansino/feature/module_run_support' 2020-07-24 08:41:51 -04:00
tpansin
322661dde1 Added support to run with python -m 2020-07-23 08:44:06 -07:00
Anish Athalye
c5e709d433 Make error for empty config a little friendlier
In the setup guide in the README, we have people start out with an empty
file (created using `touch`). Before this patch, Dotbot gave the
following error:

    Configuration file must be a list of tasks

Instead, with this patch, Dotbot says:

    Configuration file is empty, no work to do

This change was prompted by
https://github.com/anishathalye/dotbot/pull/226.
2020-06-25 16:07:23 -04:00
Anish Athalye
043373ea74 Standardize documentation for extended config
This patch adds parameter/explanation tables for the two other commands
that support extended configuration syntaxes, so now we have
identically-formatted tables for link, shell, and clean.

This change was prompted by
https://github.com/anishathalye/dotbot/issues/223.
2020-06-19 20:31:43 -04:00
Anish Athalye
8f136ee73f Remove confusing example
Without an explanation of what's going on here, this example is
unnecessarily confusing. It's a neat example, but probably not worth
explaining this in Dotbot's README.

See https://github.com/anishathalye/dotbot/issues/224.
2020-06-19 20:02:03 -04:00
Anish Athalye
f5e019105e Work around subprocess.call() issue on Windows
On POSIX-like systems, calling `subprocess.call()` with both
`shell=True` and `executable='...'` has the following behavior:

> If `shell=True`, on POSIX the _executable_ argument specifies a
> replacement shell for the default `/bin/sh`.

(via https://docs.python.org/3/library/subprocess.html?highlight=subprocess#popen-constructor)

This seems to have a similar behavior on Windows, but this is
problematic when a POSIX shell is substituted for cmd.exe. This is
because when `shell=True`, the shell is invoked with a '/c' argument,
which is the correct argument for cmd.exe but not for Bash, which
expects a '-c' argument instead. See here:
1def7754b7/Lib/subprocess.py (L1407)

This is problematic when combined with Dotbot's behavior, where the
`executable` argument is set based on `$SHELL`. For example, when
running in Git Bash, the `$SHELL` environment variable is set to Bash,
so any commands run by Dotbot will fail (because it'll invoke Bash with
a '/c' argument).

This behavior of setting the `executable` argument based on `$SHELL` was
introduced in 7593d8c134. This is the
desired behavior. See discussion in
https://github.com/anishathalye/dotbot/issues/97 and
https://github.com/anishathalye/dotbot/pull/100.

Unfortunately, this doesn't work quite right on Windows. This patch
works around the issue by avoiding setting the `executable` argument
when the platform is Windows, which is tested using
`platform.system() == 'Windows'`. This means that shell commands
executed by Dotbot on this platform will always be run using cmd.exe.
Invocations of single programs or simple commands will probably work
just fine in cmd.exe. If Bash-like behavior is desired, the user will
have to write their command as `bash -c '...'`.

This shouldn't have any implications for backwards-compatibility,
because setting the `executable` argument on Windows didn't do the right
thing anyways. Previous workarounds that users had should continue to
work with the new code.

When using Python from CYGWIN, `platform.system()` returns something
like 'CYGWIN_NT-...', so it won't be detected with the check, but this
is the correct behavior, because CYGWIN Python's `subprocess.call()` has
the POSIX-like behavior.

This patch also refactors the code to factor out the
`subprocess.call()`, which was being called in both `link.py` and
`shell.py`, so the workaround can be applied in a single place.

See the following issues/pull requests for a discussion of this bug:
- https://github.com/anishathalye/dotbot/issues/170
- https://github.com/anishathalye/dotbot/pull/177
- https://github.com/anishathalye/dotbot/issues/219

An issue has also been raised in Python's issue tracker:
- https://bugs.python.org/issue40467

Thanks to @shivapoudel for originally reporting the issue, @SuJiKiNen
for debugging it and submitting a pull request, and @mohkale for
suggesting factoring out the code so that other plugins could use it.
2020-05-01 11:52:51 -04:00
Anish Athalye
7ffaa65482 Add --only and --except command-line arguments
Internal to Dotbot, we use the name "skip" instead of "except", because
the latter is a keyword, and using a name like "except_" didn't seem as
nice.
2020-03-26 11:23:07 -04:00
Anish Athalye
5d83f9e797 Upgrade PyYAML to 5.3 2020-01-06 20:11:22 -05:00
Anish Athalye
9281d120dd Release 1.17.0 2020-01-03 16:47:57 -05:00
Anish Athalye
ec8498ffb8 Merge branch 'ypid/add/link_real_path_option' 2020-01-03 16:46:19 -05:00
Anish Athalye
320d5d0123 Add tests for canonicalize-path 2020-01-03 16:45:35 -05:00
Robin Schneider
138fdbc8d7
Add 'canonicalize-path' option to link
Dotbot had a hardcoded behaviour that the BASEDIR was always passed to
os.path.realpath which "returns the canonical path of the specified
filename, eliminating any symbolic links encountered in the path".

This might not always be desirable so this commit makes it configurable.

The use case where `canonicalize-path` comes in handy is the following:
You want to provide dotfiles in the Filesystem Hierarchy Standard under
`/usr/local/share/ypid_dotfiles/`. Now you want to provide
`.config/dotfiles` as a default in `/etc/skel`. When you now
pre-configure `/etc/skel` by running dotbot in it set has HOME, dotfiles
will refer to `/usr/local/share/ypid_dotfiles/` and not
`/etc/skel/.config/dotfiles` which does not look nice.

This is related to but not the same as the `relative` parameter used
with link commands.
2020-01-03 22:35:13 +01:00
Anish Athalye
3fcc13d803 Update dates 2020-01-03 16:19:21 -05:00
Anish Athalye
6d24613b0b Unify Vagrant and Travis-CI tests
This patch makes the tests (including the test driver) run entirely
inside Vagrant, which avoids calling the very slow `vagrant` driver many
times for running the tests. On my machine, `./test` runs in 22 seconds,
down from hundreds of seconds prior to this patch.

This also has the nice side effect of matching how the Travis CI tests
were run, so there's no need for a separate `test_travis` anymore.
2020-01-03 15:34:46 -05:00
Anish Athalye
1e1885c45a Fix incorrect use of is over ==
Comparing strings and integers with `is` is a bug: comparisons should be
done with `==`. It might not have caused observable problems in the past
because small integers and strings can be interned.
2020-01-03 15:31:24 -05:00
Anish Athalye
a7ed166817 Add Python 3.8 to Travis tests 2020-01-03 14:00:13 -05:00
Anish Athalye
e38e021ab3 Add option to clean recursively 2019-12-31 19:14:23 -05:00
Anish Athalye
81f0d74955 Fix clean not respecting defaults
Previously, clean read the defaults once, and then it updated the
setting for each entry it read. This resulted in the defaults being
clobbered and then not being respected for subsequent entries. This
patch fixes the issue by re-reading the defaults before processing each
item.

The other plugins (link, shell) do not have this problem.
2019-12-31 14:47:32 -05:00
Anish Athalye
a7bfce3e23 Merge branch 'apuignav/ignore-missing' 2019-12-31 14:26:26 -05:00
Albert Puig
eabd84bce1 Add ignore-missing option to link 2019-12-31 14:25:53 -05:00
Anish Athalye
a8380f6496 Migrate to travis-ci.com 2019-12-28 10:44:24 -05:00
Anish Athalye
2c8a0431ed Bump PyYAML version to 5.1.2 2019-11-20 10:47:10 -05:00
Anish Athalye
8667b75a73 Add example of conditional link 2019-11-14 16:30:47 -05:00
Anish Athalye
7f97a6c6d0 Make list more compact 2019-11-12 15:01:03 -05:00
Anish Athalye
2c27655500 Merge branch 'jesseleite/readme-organization' 2019-11-12 13:44:49 -05:00
Jesse Leite
daf3a7c483 Add table of contents and organize headings a bit 2019-11-12 13:44:28 -05:00
Anish Athalye
2dc876cd65 Merge branch 'paulohefagundes/remove_which' 2019-11-09 12:40:20 -05:00
Paulo Fagundes
d2913e6cee Replace which with command -v
Some distributions such as Arch Linux no longer install `which` by
default through the base package (see
https://www.archlinux.org/news/base-group-replaced-by-mandatory-base-package-manual-intervention-required/).

The maintainers have explained why `command -v` is superior:
https://www.reddit.com/r/archlinux/comments/de1er6/arch_linux_news_base_group_replaced_by_mandatory/f2v8uhu/.
2019-11-09 12:38:01 -05:00
Anish Athalye
5bb3c8a343 Release 1.16.0 2019-10-12 12:02:40 -04:00
Anish Athalye
04c113b5b8 Merge branch 'jesseleite/create-directive' 2019-10-12 11:55:18 -04:00
Jesse Leite
5a0f6676d4 Add 'create' directive to create directories 2019-10-12 11:55:09 -04:00
Anish Athalye
32741ea0ca Switch to more recent version of Debian 2019-10-12 10:36:35 -04:00
Anish Athalye
cdef01e9c4 Merge branch 'darsh12/master' 2019-09-17 08:35:08 -04:00
Darshan Patel
d6975dc660 Add instructions to ignore dirty commits 2019-09-17 08:34:58 -04:00
Anish Athalye
d20984f5ac Remove explicit specification of environment 2019-08-18 14:25:04 -04:00
Anish Athalye
4ca0cb5445 Update Travis CI config
- Use Xenial for all tests
- Drop support for Python 3.2 and Python 3.3
2019-08-18 14:17:55 -04:00
Anish Athalye
9a8d292681 Make launcher prefer python over python3
This patch makes the launcher script prefer `python`, when present, over
`python3`. This way, the launcher uses the user's preferred `python`
(which is often set up as a symbolic link to a particular python2.x or
python3.x), when available.
2019-06-26 15:00:07 -04:00
Anish Athalye
8454021d66 Merge branch 'ronalabraham/patch-1' 2019-06-05 14:32:53 -04:00
ronalabraham
017c70b5b0
Change target to source for consistency with docs 2019-06-04 15:20:00 -04:00
ronalabraham
c97472bc27
Fix path parameter's docs for linking feature 2019-06-04 14:42:21 -04:00
Anish Athalye
b062aeaf15 Simplify README
This patch removes JSON examples from the README.
2019-02-06 09:30:49 -05:00
Anish Athalye
cd04d886fe Update dates 2019-01-09 20:46:21 -05:00
Anish Athalye
fa33cda9e6 Add Python 3.7 to Travis CI tests 2019-01-09 20:45:31 -05:00
Anish Athalye
5d74f29001 Release 1.15.0 2018-11-21 19:52:03 -05:00
Anish Athalye
5e2d40939e Merge branch 'thtliife/suppress-shell-cmd' 2018-11-21 19:50:46 -05:00
Anish Athalye
dc05786693 Add test 2018-11-21 19:50:32 -05:00
Vito Giarrusso
3bda18ed9c Add quiet option to shell plugin 2018-11-21 19:50:32 -05:00
Anish Athalye
8558a5dca0 Release 1.14.1 2018-11-19 20:39:19 -05:00
Anish Athalye
b35462ab95 Merge branch 'qubidt/default-link-conditional' 2018-11-19 20:38:59 -05:00
Anish Athalye
1be9f7532b Add test 2018-11-19 20:38:29 -05:00
Bao Trinh
7add866215
Support defining default conditional option for links 2018-11-17 15:00:18 -06:00
Anish Athalye
e35b0794fd Release 1.14.0 2018-10-16 20:52:09 -04:00
Anish Athalye
1b17e8e613 Merge branch 'linkif' 2018-10-16 20:50:59 -04:00
Anish Athalye
b0ce63904c Add test for conditional linking 2018-10-16 20:22:50 -04:00
Ben Klein
a9cf9fffe4 Implement conditional linking 2018-10-16 20:22:40 -04:00
Anish Athalye
b442b9bdce Merge branch 'phallstrom/option-no-color' 2018-10-08 21:02:41 -07:00
Philip Hallstrom
a22d980cdf Add option --no-color to suppress colorization of output
By default, if output is a TTY, dotbot will colorize the output. This
patch adds the option to pass `--no-color` to dotbot to have it suppress
this colorization.
2018-10-06 16:08:26 -07:00
Anish Athalye
f197ededb1 Merge branch 'dsifford/fix-clean' 2018-08-06 20:56:11 -04:00
Anish Athalye
9332cde5ad Add test for environment variable expansion 2018-08-06 20:53:17 -04:00
Derek P Sifford
cf55ca5188 Fix clean path expansion to expand env vars 2018-08-06 20:41:42 -04:00
Anish Athalye
f95cbe2705 Release 1.12.4 2018-06-18 09:30:11 -04:00
Anish Athalye
9d967ad7e8 Include built-in plugins in PyPI distribution 2018-06-11 21:14:10 -04:00
Anish Athalye
f7a8bf10ba Fix script
It is only legal to `return` from a function or sourced script. The
right builtin to use here is `exit`.
2018-06-07 13:04:19 -04:00
Anish Athalye
e72e3b47a0 Make install script less noisy
Before this patch, the `git submodule sync` line would always print to
stdout.
2018-06-05 11:42:16 -04:00
Anish Athalye
a22838db76 Release 1.12.3 2018-06-03 15:46:52 -04:00
Anish Athalye
4ab58f6d16 Merge branch 'fix-pypi' 2018-06-03 15:46:33 -04:00
Anish Athalye
7b23188602 Fix missing packages in PyPI distribution 2018-06-01 23:08:12 -04:00
Anish Athalye
49b29da8e7 Release 1.12.2 2018-06-01 08:00:50 -04:00
Anish Athalye
1dc80c77a7 Add setup_requires
Both setuptools and wheel need to be at some minimum version to be able
to upload Markdown READMEs.
2018-06-01 07:57:23 -04:00
Anish Athalye
02923042a8 Merge branch 'pypi' 2018-06-01 07:42:12 -04:00
Anish Athalye
30caaf2780 Add PyPI package
This patch also makes the '-d' argument optional, with the base
directory defaulting to the directory of the configuration file.
2018-05-30 10:23:53 -04:00
Anish Athalye
8afca63f65 Update dates 2018-05-30 09:37:36 -04:00
Anish Athalye
b5ab016986 Merge branches 'yaml' and 'basedir' 2018-05-29 21:50:08 -04:00
Anish Athalye
7a19cd219a Point PyYAML dependency to official repository
Previously, PyYAML was hosted on BitBucket, so we had a mirror of the
repo on GitHub. Now, official hosting has moved to GitHub, so we can
point to the official repository instead. Thanks to Marco A. Feliu
<marco.feliu@nianet.org> for pointing this out.

This patch also updates the install shim to update submodule URLs. To
preserve the functionality of earlier Dotbot versions, we will need to
maintain 'https://github.com/anishathalye/pyyaml'. Because old versions
of the install shim used with new Dotbot versions will not update
submodule URLs, we will need to keep the old repository in sync with the
upstream repository as we upgrade PyYAML versions.

This patch also upgrades the dependency to PyYAML 3.12.
2018-05-24 19:07:11 -04:00
Anish Athalye
c3f271481a Fix handling of base directory
Prior to this patch, Dotbot was relying on running with the base
directory being the current working directory. In practice, it was
relying on the install shim to set up this context. It makes more sense
sense to actually execute `chdir()` within Dotbot itself, rather than
relying on the install shim to do so.
2018-05-24 12:57:49 -04:00
Anish Athalye
2f4cc0d9cb Merge branch 'glob' 2018-04-13 08:49:09 -04:00
Anish Athalye
972e80f188 Fix tests on Travis CI
The tests were failing due to the '~/bin' directory already existing on
the machine. This patch changes the tests to use the directory name
'foo'.
2018-04-13 08:49:02 -04:00
Anish Athalye
ee480d931b Fix bug 2018-04-13 08:49:02 -04:00
Anish Athalye
8d08e4b1ad Add tests for globbing 2018-04-13 08:49:02 -04:00
Anish Athalye
7d069b4ac8 Rename 'use_glob' to 'glob' 2018-04-13 08:49:02 -04:00
Ben Klein
7ebb601a10 Add use_globs to readme 2018-04-13 08:49:02 -04:00
Ben Klein
564d16fcd5 Allow force and relink on glob items 2018-04-13 08:49:02 -04:00
Ben Klein
dece710399 Implement globbing support 2018-04-13 08:49:02 -04:00
Anish Athalye
a517c4c5d0 Fix bug in test harness
Because 'bin/dotbot' is a hybrid sh/Python script that finds the
appropriate Python binary and then runs, the test code should not invoke
it as an sh script when sh does not source the pyenv initialization
script.

The fix implemented in this patch is to directly run 'bin/dotbot' as a
Python script using 'python', which when called from bash, will run with
the correct Python version (because bash is set up to source the pyenv
initialization script).
2018-03-27 21:50:31 -04:00
Anish Athalye
b7022f4abb Speed up Vagrant tests
This patch removes the snapshot/rollback system for the tests and
replaces it with a simple cleanup script that removes the appropriate
files from the VM's $HOME before every test. A side benefit is that
there is no more dependency on the Sahara plugin.

The cleanup script may not provide as good isolation between independent
tests as the previous setup, but it is orders of magnitude faster to run
without a VM rollback for every single test, and it does not break any
of the current tests.
2018-03-27 20:56:55 -04:00
Anish Athalye
9ff1db1672 Change default Python version in tests to 3.6.4 2018-03-27 18:16:55 -04:00
Anish Athalye
04fb8adbd9 Add instructions for installing test env on macOS 2018-03-27 17:42:42 -04:00
Anish Athalye
f8f46960f4 Merge branch 'nagromc/tests-instructions' 2018-03-27 17:41:14 -04:00
Morgan Courbet
174949d1fc Add instructions for installing test env on Debian 2018-03-27 17:41:07 -04:00
Anish Athalye
46b17366a3 Update to Debian 9 (Stretch) for testing 2018-03-27 17:26:34 -04:00
Anish Athalye
fe9ca6f5ed Merge branch 'klausweiss/master' 2017-07-12 21:05:48 -07:00
Mikołaj Biel
87cc8d890a Fix shell example in README (add description) 2017-07-12 19:39:36 +02:00
Anish Athalye
f1e8297255 Fix bug with shell defaults 2017-06-27 21:27:50 -07:00
Anish Athalye
0b01d56d81 Merge branch '4U6U57/patch-1' 2017-04-19 14:10:08 -04:00
August Valera
e2948b1a34 Fix README link to list of users after wiki reorg
wiki:List-of-Dotbot-Users -> wiki:Users
2017-04-19 10:59:30 -07:00
Anish Athalye
096035dd98 Add note 2017-04-19 08:46:45 -04:00
Anish Athalye
53eb3851d1 Make launcher find python binary 2017-04-19 08:40:15 -04:00
Anish Athalye
32aa475903 Number repeated names for easier identification 2017-04-18 22:50:28 -04:00
Anish Athalye
c29ba8d722 Add documentation on force option for clean plugin 2017-03-26 11:50:57 -04:00
Anish Athalye
bf2a9330da Merge branch 'dein0s/feature/clean_plugin_add...' 2017-03-26 11:50:41 -04:00
Anish Athalye
190c800272 Fix tests for Travis CI
This patch makes it so the `travis_cleanup` function removes broken
symlinks properly.
2017-03-26 11:50:01 -04:00
dein0s
a2a9e1fb91 Add force option to clean plugin 2017-03-26 11:19:26 -04:00
Anish Athalye
ef558f85d9 Update dates 2017-03-06 15:20:35 -05:00
Anish Athalye
2abfa7fd91 Add tests for more Python versions 2017-03-06 15:20:27 -05:00
Anish Athalye
847cb101d4 Merge branch 'casey/empty-source' 2016-11-23 14:13:00 -05:00
Casey Rodarmor
d2e20c77db Allow empty link sources
If the source for a link is null, use the basename of the destination
with a single leading '.' removed, if present.
2016-11-23 14:11:45 -05:00
Anish Athalye
4468fff67a Merge branch 'ael-code/skip_nonexisting' 2016-11-19 10:27:21 -06:00
ael-code
58c8b98277 Fix error reporting in link plugin 2016-11-19 10:27:13 -06:00
Anish Athalye
295f8e3160 Merge branch 'casey/tilde-warning' 2016-11-18 23:28:22 -06:00
Casey Rodarmor
54346fa942 Mention in readme that ~ is null in YAML
This is a weird YAML gotcha, and it seems worth mentioning in the readme
since Dotbot users are likely to run into it.
2016-11-18 23:27:54 -06:00
Anish Athalye
b482cbda58 Make force only delete files when target exists 2016-11-17 14:12:22 -05:00
Anish Athalye
913d5484ca Merge branch 'bixel/master' 2016-11-17 13:50:32 -05:00
Kevin Heinicke
7593d8c134 Use current $SHELL for shell plugin 2016-11-17 13:50:12 -05:00
Anish Athalye
28959a3f31 Fix user expansion in link source
Previous to this patch, having a config like the following would not
work properly:

    - link:
        ~/a: ~/b

This was because the '~' was expanded on the left hand side (the link
target), but not the right hand side (the link source). It was necessary
to use a workaround like this:

    - link:
        ~/a: $HOME/b

This was because variable expansion was being done, but user expansion
was not being done.

This commit adds support for using '~' in the link source.
2016-08-17 18:27:47 -07:00
Anish Athalye
f04b94d4ae Add info about initializing repo during setup
This change was suggested by Brian Jacobowski
<bjacobowski.dev@gmail.com>.
2016-08-15 17:44:30 -07:00
Anish Athalye
a836261d02 Fix variable expansion in extended syntax 2016-08-02 10:15:27 -07:00
Anish Athalye
0618bc70cc Change inspiration link to point to wiki 2016-04-10 02:17:21 -04:00
Anish Athalye
191559601a Fix linking when using both relink and relative
Prior to this patch, the following config led to incorrect behavior:

    - link:
        ~/.folder/file:
          path: file
          create: true
          relative: true
          relink: true

Prior to the change, running the config the first time would result in
expected behavior. However, running the config for the second time would
result in deletion and re-creation of the link (even when the link is
correct).

This patch improves the interaction of relink and relative, taking
relative paths into account when checking the validity of existing links
in the `_delete()` method.
2016-04-07 14:45:46 -04:00
Anish Athalye
3d9b3ae2a8 Fix link 2016-03-26 09:16:41 -04:00
Anish Athalye
1374416362 Add link to wiki 2016-03-20 08:34:29 -04:00
Anish Athalye
56aeffa387 Switch to Debian for testing in Vagrant 2016-03-05 02:07:01 -05:00
Anish Athalye
f52bbd1eec Add default options
This feature was implemented with feedback from Aleks Kamko
<aykamko@gmail.com> and Casey Rodarmor <casey@rodarmor.com>.
2016-03-03 09:42:50 -05:00
Anish Athalye
daf8d82e02 Add functionality to create relative links
This commit adds an option to the extended configuration syntax for
linking files and directories. Enabling the relative option makes it so
that symbolic links are created with relative paths instead of absolute
paths.
2016-02-14 23:06:52 -05:00
Anish Athalye
c402396c58 Add documentation and tests for plugins 2016-02-06 15:23:39 -05:00
Anish Athalye
aaf093b124 Merge branch 'casey/directive-plugins' 2016-02-06 15:17:47 -05:00
Casey Rodarmor
eeb4c284fb Add plugin loader 2016-02-06 15:14:35 -05:00
Anish Athalye
ba9e9cbe70 Update dates 2016-01-31 08:03:06 -05:00
Anish Athalye
38666c8937 Merge branch 'bchretien/master' 2016-01-18 04:16:04 -05:00
Benjamin Chrétien
c536f5fa3f Travis: use container-based infrastructure 2016-01-18 13:14:58 +09:00
Benjamin Chrétien
9670a7b6fa Travis: add guard to test_travis 2016-01-18 13:14:56 +09:00
Anish Athalye
80c2d83855 Move Travis CI test script 2016-01-16 16:43:58 -05:00
Anish Athalye
2e64388b20 Merge branch 'bchretien/topic/travis-ci' 2016-01-16 15:43:47 -05:00
Benjamin Chrétien
07a26a843e Add Travis CI support
The test suite has been slightly modified to allow running tests without
Vagrant on Travis.
2016-01-16 17:34:52 +09:00
Anish Athalye
582edd6bd3 Fix compatibility with Python 3
This patch removes a stray print statement that was causing problems
with Python 3.
2016-01-15 01:21:16 -05:00
Anish Athalye
47ad7f4d3b Use file extension to select config file parser
This patch makes Dotbot provide better error messages when parsing JSON
files.
2016-01-13 13:46:41 -05:00
Anish Athalye
c48d16cbce Use standard library JSON parser for JSON files
This patch reverts the changes to the README made in
57265f78b4 and makes it so that Dotbot
supports JSON files with tab characters.
2016-01-13 11:29:12 -05:00
Anish Athalye
9250bef422 Add hook showing usage to README 2016-01-11 14:02:16 -05:00
Anish Athalye
57265f78b4 Add note to README about JSON parsing
Casey Rodarmor <casey@rodarmor.com> pointed out that Dotbot parses JSON
files as YAML. The YAML format doesn't allow tab characters, which are
normally allowed in JSON. This patch adds a note about this to the
README.
2016-01-11 13:36:16 -05:00
Anish Athalye
5babc8f562 Add ability to test with specific Python versions 2015-10-22 22:08:35 -04:00
Anish Athalye
30dc7d5788 Fix typo 2015-10-19 10:13:23 -04:00
Anish Athalye
bb209bd17f Fix typo 2015-08-17 21:30:56 -07:00
76 changed files with 3704 additions and 907 deletions

View file

@ -13,5 +13,5 @@ indent_size = 4
[*.bash] [*.bash]
indent_size = 4 indent_size = 4
[*.md] [*.yml]
trim_trailing_whitespace = false indent_size = 2

57
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,57 @@
name: CI
on:
push:
pull_request:
schedule:
- cron: '0 8 * * 6'
jobs:
test:
env:
PIP_DISABLE_PIP_VERSION_CHECK: 1
strategy:
fail-fast: false
matrix:
os: ["ubuntu-20.04", "macos-latest"]
python: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.9"]
include:
- os: "windows-latest"
python: "3.8"
- os: "windows-latest"
python: "3.9"
- os: "windows-latest"
python: "3.10"
- os: "windows-latest"
python: "3.11"
- os: "windows-latest"
python: "3.12"
exclude:
- os: "macos-latest"
python: "3.6"
- os: "macos-latest"
python: "3.7"
runs-on: ${{ matrix.os }}
name: "Test: Python ${{ matrix.python }} on ${{ matrix.os }}"
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
allow-prereleases: true
- name: "Install dependencies"
run: |
python -m pip install --upgrade pip setuptools
python -m pip install tox tox-gh-actions
- name: "Run tests"
run: |
python -m tox
python -m tox -e coverage_report
- uses: codecov/codecov-action@v3
fmt:
name: Format
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: psf/black@stable
- uses: isort/isort-action@v1

10
.gitignore vendored
View file

@ -1 +1,11 @@
*.egg-info
*.pyc *.pyc
.coverage*
.eggs/
.idea/
.tox/
.venv/
build/
coverage.xml
dist/
htmlcov/

2
.gitmodules vendored
View file

@ -1,4 +1,4 @@
[submodule "lib/pyyaml"] [submodule "lib/pyyaml"]
path = lib/pyyaml path = lib/pyyaml
url = https://github.com/anishathalye/pyyaml url = https://github.com/yaml/pyyaml
ignore = dirty ignore = dirty

52
CHANGELOG.md Normal file
View file

@ -0,0 +1,52 @@
Note: this changelog only lists feature additions, not bugfixes. For details on
those, see the Git history.
- v1.20
- Drop support for Python 2 and old versions of Python 3: the minimum
version supported is now Python 3.6
- v1.19
- Add `mode:` option for `create`
- Add `exclude:` option for `link`
- v1.18
- Add `--only` and `--except` flags
- Add support to run with `python -m dotbot`
- Add `--force-color` option
- v1.17
- Add `canonicalize-path:` option for `link`
- v1.16
- Add `create` plugin
- v1.15
- Add `quiet:` option for `shell`
- v1.14
- Add `if:` option for `link`
- v1.13
- Add `--no-color` flag
- v1.12
- Add globbing support to `link`
- v1.11
- Add force option to `clean` to remove all broken symlinks
- v1.10
- Update `link` to support shorthand syntax for links
- v1.9
- Add support for default options for commands
- v1.8
- Update `link` to be able to create relative links
- v1.7
- Add support for plugins
- v1.6
- Update `link` to expand environment variables in paths
- v1.5
- Update `link` to be able to automatically overwrite broken symlinks
- v1.4
- Update `shell` to allow for selectively enabling/disabling stdin, stdout,
and stderr
- v1.3
- Add support for YAML format configs
- v1.2
- Update `link` to be able to force create links (deleting things that were
previously there)
- Update `link` to be able to create parent directories
- v1.1
- Update `clean` to remove old broken symlinks
- v1.0
- Initial commit

View file

@ -1,7 +1,7 @@
Contributing Contributing
============ ============
All kinds contributions to Dotbot are greatly appreciated. For someone All kinds of contributions to Dotbot are greatly appreciated. For someone
unfamiliar with the code base, the most efficient way to contribute is usually unfamiliar with the code base, the most efficient way to contribute is usually
to submit a [feature request](#feature-requests) or [bug report](#bug-reports). to submit a [feature request](#feature-requests) or [bug report](#bug-reports).
If you want to dive into the source code, you can submit a [patch](#patches) as If you want to dive into the source code, you can submit a [patch](#patches) as
@ -50,6 +50,41 @@ used in the rest of the project. The version history should be clean, and
commit messages should be descriptive and [properly commit messages should be descriptive and [properly
formatted][commit-messages]. formatted][commit-messages].
When preparing a patch, it's recommended that you add unit tests
that demonstrate the bug is fixed (or that the feature works).
You can run the tests on your local machine by installing the `dev` extras.
The steps below do this using a virtual environment:
```shell
# Create a local virtual environment
$ python -m venv .venv
# Activate the virtual environment
# Cygwin, Linux, and MacOS:
$ . .venv/bin/activate
# Windows Powershell:
$ & .venv\Scripts\Activate.ps1
# Update pip and setuptools
(.venv) $ python -m pip install -U pip setuptools
# Install dotbot and its development dependencies
(.venv) $ python -m pip install -e .[dev]
# Run the unit tests
(.venv) $ tox
```
If you prefer to run the tests in an isolated container using Docker, you can
do so with the following:
```
docker run -it --rm -v "${PWD}:/dotbot" -w /dotbot python:3.10-alpine /bin/sh
```
After spawning the container, follow the same instructions as above (create a
virtualenv, ..., run the tests).
--- ---
If you have any questions about anything, feel free to [ask][email]! If you have any questions about anything, feel free to [ask][email]!

View file

@ -1,7 +1,7 @@
The MIT License (MIT) The MIT License (MIT)
===================== =====================
**Copyright (c) 2014-2015 Anish Athalye (me@anishathalye.com)** **Copyright (c) Anish Athalye (me@anishathalye.com)**
Permission is hereby granted, free of charge, to any person obtaining a copy of 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 this software and associated documentation files (the "Software"), to deal in

381
README.md
View file

@ -1,5 +1,19 @@
Dotbot # Dotbot [![Build Status](https://github.com/anishathalye/dotbot/workflows/CI/badge.svg)](https://github.com/anishathalye/dotbot/actions?query=workflow%3ACI) [![Coverage](https://codecov.io/gh/anishathalye/dotbot/branch/master/graph/badge.svg)](https://app.codecov.io/gh/anishathalye/dotbot) [![PyPI](https://img.shields.io/pypi/v/dotbot.svg)](https://pypi.org/pypi/dotbot/) [![Python 3.6+](https://img.shields.io/badge/python-3.6%2B-blue)](https://pypi.org/pypi/dotbot/)
======
Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles
&& ./install`, even on a freshly installed system!
- [Rationale](#rationale)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Directives](#directives) ([Link](#link), [Create](#create), [Shell](#shell), [Clean](#clean), [Defaults](#defaults))
- [Plugins](#plugins)
- [Command-line Arguments](#command-line-arguments)
- [Wiki][wiki]
---
## Rationale
Dotbot is a tool that bootstraps your dotfiles (it's a [Dot]files Dotbot is a tool that bootstraps your dotfiles (it's a [Dot]files
[bo]o[t]strapper, get it?). It does *less* than you think, because version [bo]o[t]strapper, get it?). It does *less* than you think, because version
@ -10,11 +24,13 @@ dependencies and no installation required. Dotbot can also be a drop-in
replacement for any other tool you were using to manage your dotfiles, and replacement for any other tool you were using to manage your dotfiles, and
Dotbot is VCS-agnostic -- it doesn't make any attempt to manage your dotfiles. Dotbot is VCS-agnostic -- it doesn't make any attempt to manage your dotfiles.
If you want an in-depth tutorial about organizing your dotfiles, see this [blog See [this blog
post][managing-dotfiles-post]. post](https://www.anishathalye.com/2014/08/03/managing-your-dotfiles/) or more
resources on the [tutorials
page](https://github.com/anishathalye/dotbot/wiki/Tutorials) for more detailed
explanations of how to organize your dotfiles.
Get Running in 5 Minutes ## Getting Started
------------------------
### Starting Fresh? ### Starting Fresh?
@ -27,20 +43,22 @@ you're just looking for [some inspiration][inspiration], we've got you covered.
The following will help you get set up using Dotbot in just a few steps. The following will help you get set up using Dotbot in just a few steps.
If you're using Git, you can add Dotbot as a submodule: If you're using **Git**, you can add Dotbot as a submodule:
```bash ```bash
# replace with the path to your dotfiles cd ~/.dotfiles # replace with the path to your dotfiles
cd ~/.dotfiles git init # initialize repository if needed
git submodule add https://github.com/anishathalye/dotbot git submodule add https://github.com/anishathalye/dotbot
git config -f .gitmodules submodule.dotbot.ignore dirty # ignore dirty commits in the submodule
cp dotbot/tools/git-submodule/install . cp dotbot/tools/git-submodule/install .
touch install.conf.yaml touch install.conf.yaml
``` ```
If you're using Mercurial, you can add Dotbot as a subrepo: If you're using **Mercurial**, you can add Dotbot as a subrepo:
```bash ```bash
# replace with the path to your dotfiles cd ~/.dotfiles # replace with the path to your dotfiles
hg init # initialize repository if needed
echo "dotbot = [git]https://github.com/anishathalye/dotbot" > .hgsub echo "dotbot = [git]https://github.com/anishathalye/dotbot" > .hgsub
hg add .hgsub hg add .hgsub
git clone https://github.com/anishathalye/dotbot git clone https://github.com/anishathalye/dotbot
@ -48,6 +66,11 @@ cp dotbot/tools/hg-subrepo/install .
touch install.conf.yaml touch install.conf.yaml
``` ```
If you are using PowerShell instead of a POSIX shell, you can use the provided
`install.ps1` script instead of `install`. On Windows, Dotbot only supports
Python 3.8+, and it requires that your account is [allowed to create symbolic
links][windows-symlinks].
To get started, you just need to fill in the `install.conf.yaml` and Dotbot To get started, you just need to fill in the `install.conf.yaml` and Dotbot
will take care of the rest. To help you get started we have [an will take care of the rest. To help you get started we have [an
example](#full-example) config file as well as [configuration example](#full-example) config file as well as [configuration
@ -62,8 +85,20 @@ editing the variables in the `install` script appropriately.
Setting up Dotbot as a submodule or subrepo locks it on the current version. Setting up Dotbot as a submodule or subrepo locks it on the current version.
You can upgrade Dotbot at any point. If using a submodule, run `git submodule You can upgrade Dotbot at any point. If using a submodule, run `git submodule
update --remote dotbot`, substituting `dotbot` with the path to the Dotbot update --remote dotbot`, substituting `dotbot` with the path to the Dotbot
submodule. If using a subrepo, run `git fetch && git checkout origin/master` in submodule; be sure to commit your changes before running `./install`, otherwise
the Dotbot directory. the old version of Dotbot will be checked out by the install script. If using a
subrepo, run `git fetch && git checkout origin/master` in the Dotbot directory.
If you prefer, you can install Dotbot from [PyPI] and call it as a command-line
program:
```bash
pip install dotbot
touch install.conf.yaml
```
In this case, rather than running `./install`, you can invoke Dotbot with
`dotbot -c <path to configuration file>`.
### Full Example ### Full Example
@ -72,50 +107,36 @@ Here's an example of a complete configuration.
The conventional name for the configuration file is `install.conf.yaml`. The conventional name for the configuration file is `install.conf.yaml`.
```yaml ```yaml
- defaults:
link:
relink: true
- clean: ['~'] - clean: ['~']
- link: - link:
~/.dotfiles: ''
~/.tmux.conf: tmux.conf ~/.tmux.conf: tmux.conf
~/.vim: vim/ ~/.vim: vim
~/.vimrc: vimrc ~/.vimrc: vimrc
- create:
- ~/downloads
- ~/.vim/undo-history
- shell: - shell:
- [git submodule update --init --recursive, Installing submodules] - [git submodule update --init --recursive, Installing submodules]
``` ```
The configuration file can also be written in JSON. Here is the JSON equivalent The configuration file is typically written in YAML, but it can also be written
of the YAML configuration given above. in JSON (which is a [subset of YAML][json2yaml]). JSON configuration files are
conventionally named `install.conf.json`.
The conventional name for this file is `install.conf.json`.
```json
[
{
"clean": ["~"]
},
{
"link": {
"~/.dotfiles": "",
"~/.tmux.conf": "tmux.conf",
"~/.vim": "vim/",
"~/.vimrc": "vimrc"
}
},
{
"shell": [
["git submodule update --init --recursive", "Installing submodules"]
]
}
]
```
## Configuration ## Configuration
Dotbot uses YAML or JSON formatted configuration files to let you specify how to Dotbot uses YAML or JSON-formatted configuration files to let you specify how
set up your dotfiles. Currently, Dotbot knows how to [link](#link) files and to set up your dotfiles. Currently, Dotbot knows how to [link](#link) files and
folders, execute [shell](#shell) commands, and [clean](#clean) directories of folders, [create](#create) folders, execute [shell](#shell) commands, and
broken symbolic links. [clean](#clean) directories of broken symbolic links. Dotbot also supports user
[plugins](#plugins) for custom commands.
**Ideally, bootstrap configurations should be idempotent. That is, the **Ideally, bootstrap configurations should be idempotent. That is, the
installer should be able to be run multiple times without causing any installer should be able to be run multiple times without causing any
@ -128,7 +149,14 @@ Tasks are run in the order in which they are specified. Commands within a task
do not have a defined ordering. do not have a defined ordering.
When writing nested constructs, keep in mind that YAML is whitespace-sensitive. When writing nested constructs, keep in mind that YAML is whitespace-sensitive.
Following the formatting used in the examples is a good idea. Following the formatting used in the examples is a good idea. If a YAML
configuration file is not behaving as you expect, try inspecting the
[equivalent JSON][json2yaml] and check that it is correct.
## Directives
Most Dotbot commands support both a simplified and extended syntax, and they
can also be configured via setting [defaults](#defaults).
### Link ### Link
@ -140,16 +168,40 @@ files if necessary. Environment variables in paths are automatically expanded.
Link commands are specified as a dictionary mapping targets to source Link commands are specified as a dictionary mapping targets to source
locations. Source locations are specified relative to the base directory (that locations. Source locations are specified relative to the base directory (that
is specified when running the installer). Source directory names should contain is specified when running the installer). If linking directories, *do not*
a trailing "/" character. include a trailing slash.
Link commands support an (optional) extended configuration. In this type of Link commands support an optional extended configuration. In this type of
configuration, instead of specifying source locations directly, targets are configuration, instead of specifying source locations directly, targets are
mapped to extended configuration dictionaries. These dictionaries map `path` to mapped to extended configuration dictionaries.
the source path, specify `create` as `true` if the parent directory should be
created if necessary, specify `relink` as `true` if incorrect symbolic links | Parameter | Explanation |
should be automatically overwritten, and specify `force` as `true` if the file | --- | --- |
or directory should be forcibly linked. | `path` | The source for the symlink, the same as in the shortcut syntax (default: null, automatic (see below)) |
| `create` | When true, create parent directories to the link as needed. (default: false) |
| `relink` | Removes the old target if it's a symlink (default: false) |
| `force` | Force removes the old target, file or folder, and forces a new link (default: false) |
| `relative` | Use a relative path to the source when creating the symlink (default: false, absolute links) |
| `canonicalize` | Resolve any symbolic links encountered in the source to symlink to the canonical path (default: true, real paths) |
| `if` | Execute this in your `$SHELL` and only link if it is successful. |
| `ignore-missing` | Do not fail if the source is missing and create the link anyway (default: false) |
| `glob` | Treat `path` as a glob pattern, expanding patterns referenced below, linking all *files* matched. (default: false) |
| `exclude` | Array of glob patterns to remove from glob matches. Uses same syntax as `path`. Ignored if `glob` is `false`. (default: empty, keep all matches) |
| `prefix` | Prepend prefix prefix to basename of each file when linked, when `glob` is `true`. (default: '') |
When `glob: True`, Dotbot uses [glob.glob](https://docs.python.org/3/library/glob.html#glob.glob) to resolve glob paths, expanding Unix shell-style wildcards, which are **not** the same as regular expressions; Only the following are expanded:
| Pattern | Meaning |
|:---------|:-----------------------------------|
| `*` | matches anything |
| `**` | matches any **file**, recursively |
| `?` | matches any single character |
| `[seq]` | matches any character in `seq` |
| `[!seq]` | matches any character not in `seq` |
However, due to the design of `glob.glob`, using a glob pattern such as `config/*`, will **not** match items that begin with `.`. To specifically capture items that being with `.`, you will need to include the `.` in the pattern, like this: `config/.*`.
When using glob with the `exclude:` option, the paths in the exclude paths should be relative to the base directory, same as the glob pattern itself. For example, if a glob pattern `vim/*` matches directories `vim/autoload`, `vim/ftdetect`, `vim/ftplugin`, and `vim/spell`, and you want to ignore the spell directory, then you should use `exclude: ["vim/spell"]` (not just `"spell"`).
#### Example #### Example
@ -157,14 +209,107 @@ or directory should be forcibly linked.
- link: - link:
~/.config/terminator: ~/.config/terminator:
create: true create: true
path: config/terminator/ path: config/terminator
~/.vim: vim/ ~/.vim: vim
~/.vimrc: ~/.vimrc:
relink: true relink: true
path: vimrc path: vimrc
~/.zshrc: ~/.zshrc:
force: true force: true
path: zshrc path: zshrc
~/.hammerspoon:
if: '[ `uname` = Darwin ]'
path: hammerspoon
~/.config/:
path: dotconf/config/**
~/:
glob: true
path: dotconf/*
prefix: '.'
```
If the source location is omitted or set to `null`, Dotbot will use the
basename of the destination, with a leading `.` stripped if present. This makes
the following two config files equivalent.
Explicit sources:
```yaml
- link:
~/bin/ack: ack
~/.vim: vim
~/.vimrc:
relink: true
path: vimrc
~/.zshrc:
force: true
path: zshrc
~/.config/:
glob: true
path: config/*
relink: true
exclude: [ config/Code ]
~/.config/Code/User/:
create: true
glob: true
path: config/Code/User/*
relink: true
```
Implicit sources:
```yaml
- link:
~/bin/ack:
~/.vim:
~/.vimrc:
relink: true
~/.zshrc:
force: true
~/.config/:
glob: true
path: config/*
relink: true
exclude: [ config/Code ]
~/.config/Code/User/:
create: true
glob: true
path: config/Code/User/*
relink: true
```
### Create
Create commands specify empty directories to be created. This can be useful
for scaffolding out folders or parent folder structure required for various
apps, plugins, shell commands, etc.
#### Format
Create commands are specified as an array of directories to be created. If you
want to use the optional extended configuration, create commands are specified
as dictionaries. For convenience, it's permissible to leave the options blank
(null) in the dictionary syntax.
| Parameter | Explanation |
| --- | --- |
| `mode` | The file mode to use for creating the leaf directory (default: 0777) |
The `mode` parameter is treated in the same way as in Python's
[os.mkdir](https://docs.python.org/3/library/os.html#mkdir-modebits). Its
behavior is platform-dependent. On Unix systems, the current umask value is
first masked out.
#### Example
```yaml
- create:
- ~/downloads
- ~/.vim/undo-history
- create:
~/.ssh:
mode: 0700
~/projects:
``` ```
### Shell ### Shell
@ -181,21 +326,35 @@ Another way is to specify a two element array where the first element is the
shell command and the second is an optional human-readable description. shell command and the second is an optional human-readable description.
Shell commands support an extended syntax as well, which provides more Shell commands support an extended syntax as well, which provides more
fine-grained control. A command can be specified as a dictionary that contains fine-grained control.
the command to be run, a description, and whether `stdin`, `stdout`, and
`stderr` are enabled. In this syntax, all keys are optional except for the | Parameter | Explanation |
command itself. | --- | --- |
| `command` | The command to be run |
| `description` | A human-readable message describing the command (default: null) |
| `quiet` | Show only the description but not the command in log output (default: false) |
| `stdin` | Allow a command to read from standard input (default: false) |
| `stdout` | Show a command's output from stdout (default: false) |
| `stderr` | Show a command's error output from stderr (default: false) |
Note that `quiet` controls whether the command (a string) is printed in log
output, it does not control whether the output from running the command is
printed (that is controlled by `stdout` / `stderr`). When a command's `stdin` /
`stdout` / `stderr` is not enabled (which is the default), it's connected to
`/dev/null`, disabling input and hiding output.
#### Example #### Example
```yaml ```yaml
- shell: - shell:
- mkdir -p ~/src - chsh -s $(which zsh)
- [mkdir -p ~/downloads, Creating downloads directory] - [chsh -s $(which zsh), Making zsh the default shell]
- -
command: read var && echo Your variable is $var command: read var && echo Your variable is $var
stdin: true stdin: true
stdout: true stdout: true
description: Reading and printing variable
quiet: true
- -
command: read fail command: read fail
stderr: true stderr: true
@ -205,33 +364,115 @@ command itself.
Clean commands specify directories that should be checked for dead symbolic Clean commands specify directories that should be checked for dead symbolic
links. These dead links are removed automatically. Only dead links that point links. These dead links are removed automatically. Only dead links that point
to the dotfiles directory are removed. to somewhere within the dotfiles directory are removed unless the `force`
option is set to `true`.
#### Format #### Format
Clean commands are specified as an array of directories to be cleaned. Clean commands are specified as an array of directories to be cleaned.
Clean commands also support an extended configuration syntax.
| Parameter | Explanation |
| --- | --- |
| `force` | Remove dead links even if they don't point to a file inside the dotfiles directory (default: false) |
| `recursive` | Traverse the directory recursively looking for dead links (default: false) |
Note: using the `recursive` option for `~` is not recommended because it will
be slow.
#### Example #### Example
```yaml ```yaml
- clean: ['~'] - clean: ['~']
- clean:
~/:
force: true
~/.config:
recursive: true
``` ```
Contributing ### Defaults
------------
Default options for plugins can be specified so that options don't have to be
repeated many times. This can be very useful to use with the link command, for
example.
Defaults apply to all commands that come after setting the defaults. Defaults
can be set multiple times; each change replaces the defaults with a new set of
options.
#### Format
Defaults are specified as a dictionary mapping action names to settings, which
are dictionaries from option names to values.
#### Example
```yaml
- defaults:
link:
create: true
relink: true
```
### Plugins
Dotbot also supports custom directives implemented by plugins. Plugins are
implemented as subclasses of `dotbot.Plugin`, so they must implement
`can_handle()` and `handle()`. The `can_handle()` method should return `True`
if the plugin can handle an action with the given name. The `handle()` method
should do something and return whether or not it completed successfully.
All built-in Dotbot directives are written as plugins that are loaded by
default, so those can be used as a reference when writing custom plugins.
Plugins are loaded using the `--plugin` and `--plugin-dir` options, using
either absolute paths or paths relative to the base directory. It is
recommended that these options are added directly to the `install` script.
See [here][plugins] for a current list of plugins.
## Command-line Arguments
Dotbot takes a number of command-line arguments; you can run Dotbot with
`--help`, e.g. by running `./install --help`, to see the full list of options.
Here, we highlight a couple that are particularly interesting.
### `--only`
You can call `./install --only [list of directives]`, such as `./install --only
link`, and Dotbot will only run those sections of the config file.
### `--except`
You can call `./install --except [list of directives]`, such as `./install
--except shell`, and Dotbot will run all the sections of the config file except
the ones listed.
## Wiki
Check out the [Dotbot wiki][wiki] for more information, tips and tricks,
user-contributed plugins, and more.
## Contributing
Do you have a feature request, bug report, or patch? Great! See Do you have a feature request, bug report, or patch? Great! See
[CONTRIBUTING.md][contributing] for information on what you can do about that. [CONTRIBUTING.md][contributing] for information on what you can do about that.
License ## License
-------
Copyright (c) 2014-2015 Anish Athalye. Released under the MIT License. See Copyright (c) Anish Athalye. Released under the MIT License. See
[LICENSE.md][license] for details. [LICENSE.md][license] for details.
[init-dotfiles]: https://github.com/Aviator45003/init-dotfiles [PyPI]: https://pypi.org/project/dotbot/
[init-dotfiles]: https://github.com/Vaelatern/init-dotfiles
[dotfiles-template]: https://github.com/anishathalye/dotfiles_template [dotfiles-template]: https://github.com/anishathalye/dotfiles_template
[inspiration]: https://github.com/anishathalye/dotfiles_template#inspiration [inspiration]: https://github.com/anishathalye/dotbot/wiki/Users
[managing-dotfiles-post]: http://www.anishathalye.com/2014/08/03/managing-your-dotfiles/ [windows-symlinks]: https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links
[json2yaml]: https://www.json2yaml.com/
[plugins]: https://github.com/anishathalye/dotbot/wiki/Plugins
[wiki]: https://github.com/anishathalye/dotbot/wiki
[contributing]: CONTRIBUTING.md [contributing]: CONTRIBUTING.md
[license]: LICENSE.md [license]: LICENSE.md

View file

@ -1,7 +1,30 @@
#!/usr/bin/env python #!/usr/bin/env sh
# This is a valid shell script and also a valid Python script. When this file
# is executed as a shell script, it finds a python binary and executes this
# file as a Python script, passing along all of the command line arguments.
# When this file is executed as a Python script, it loads and runs Dotbot. This
# is useful because we don't know the name of the python binary.
''':' # begin python string; this line is interpreted by the shell as `:`
command -v python3 >/dev/null 2>&1 && exec python3 "$0" "$@"
command -v python >/dev/null 2>&1 && exec python "$0" "$@"
>&2 echo "error: cannot find python"
exit 1
'''
# python code
import sys, os import sys, os
# this file is syntactically valid Python 2; bail out if the interpreter is Python 2
if sys.version_info[0] < 3:
print('error: this version of Dotbot is not compatible with Python 2:\nhttps://github.com/anishathalye/dotbot/wiki/Troubleshooting#python-2')
exit(1)
if sys.version_info < (3, 6):
print('error: this version of Dotbot requires Python 3.6+')
exit(1)
PROJECT_ROOT_DIRECTORY = os.path.dirname( PROJECT_ROOT_DIRECTORY = os.path.dirname(
os.path.dirname(os.path.realpath(__file__))) os.path.dirname(os.path.realpath(__file__)))
@ -9,11 +32,7 @@ def inject(lib_path):
path = os.path.join(PROJECT_ROOT_DIRECTORY, 'lib', lib_path) path = os.path.join(PROJECT_ROOT_DIRECTORY, 'lib', lib_path)
sys.path.insert(0, path) sys.path.insert(0, path)
# version dependent libraries inject('pyyaml/lib')
if sys.version_info[0] >= 3:
inject('pyyaml/lib3')
else:
inject('pyyaml/lib')
if os.path.exists(os.path.join(PROJECT_ROOT_DIRECTORY, 'dotbot')): if os.path.exists(os.path.join(PROJECT_ROOT_DIRECTORY, 'dotbot')):
if PROJECT_ROOT_DIRECTORY not in sys.path: if PROJECT_ROOT_DIRECTORY not in sys.path:

View file

@ -1 +1,4 @@
from .cli import main from .cli import main
from .plugin import Plugin
__version__ = "1.20.1"

4
dotbot/__main__.py Normal file
View file

@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
main()

View file

@ -1,51 +1,167 @@
from argparse import ArgumentParser import glob
import os
import subprocess
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
import dotbot
from .config import ConfigReader, ReadingError from .config import ConfigReader, ReadingError
from .dispatcher import Dispatcher, DispatchError from .dispatcher import Dispatcher, DispatchError
from .messenger import Messenger from .messenger import Level, Messenger
from .messenger import Level from .plugins import Clean, Create, Link, Shell
from .util import module
def add_options(parser): def add_options(parser):
parser.add_argument('-Q', '--super-quiet', dest='super_quiet', action='store_true', parser.add_argument(
help='suppress almost all output') "-Q", "--super-quiet", action="store_true", help="suppress almost all output"
parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', )
help='suppress most output') parser.add_argument("-q", "--quiet", action="store_true", help="suppress most output")
parser.add_argument('-v', '--verbose', dest='verbose', action='store_true', parser.add_argument(
help='enable verbose output') "-v",
parser.add_argument('-d', '--base-directory', nargs=1, "--verbose",
dest='base_directory', help='execute commands from within BASEDIR', action="count",
metavar='BASEDIR', required=True) default=0,
parser.add_argument('-c', '--config-file', nargs=1, dest='config_file', help="enable verbose output\n"
help='run commands given in CONFIGFILE', metavar='CONFIGFILE', "-v: typical verbose\n"
required=True) "-vv: also, set shell commands stderr/stdout to true",
)
parser.add_argument(
"-d", "--base-directory", help="execute commands from within BASEDIR", metavar="BASEDIR"
)
parser.add_argument(
"-c", "--config-file", help="run commands given in CONFIGFILE", metavar="CONFIGFILE"
)
parser.add_argument(
"-p",
"--plugin",
action="append",
dest="plugins",
default=[],
help="load PLUGIN as a plugin",
metavar="PLUGIN",
)
parser.add_argument(
"--disable-built-in-plugins", action="store_true", help="disable built-in plugins"
)
parser.add_argument(
"--plugin-dir",
action="append",
dest="plugin_dirs",
default=[],
metavar="PLUGIN_DIR",
help="load all plugins in PLUGIN_DIR",
)
parser.add_argument(
"--only", nargs="+", help="only run specified directives", metavar="DIRECTIVE"
)
parser.add_argument(
"--except", nargs="+", dest="skip", help="skip specified directives", metavar="DIRECTIVE"
)
parser.add_argument(
"--force-color", dest="force_color", action="store_true", help="force color output"
)
parser.add_argument(
"--no-color", dest="no_color", action="store_true", help="disable color output"
)
parser.add_argument(
"--version", action="store_true", help="show program's version number and exit"
)
parser.add_argument(
"-x",
"--exit-on-failure",
dest="exit_on_failure",
action="store_true",
help="exit after first failed directive",
)
def read_config(config_file): def read_config(config_file):
reader = ConfigReader(config_file) reader = ConfigReader(config_file)
return reader.get_config() return reader.get_config()
def main(): def main():
log = Messenger() log = Messenger()
try: try:
parser = ArgumentParser() parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
add_options(parser) add_options(parser)
options = parser.parse_args() options = parser.parse_args()
if (options.super_quiet): if options.version:
try:
with open(os.devnull) as devnull:
git_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=os.path.dirname(os.path.abspath(__file__)),
stderr=devnull,
).decode("ascii")
hash_msg = " (git %s)" % git_hash[:10]
except (OSError, subprocess.CalledProcessError):
hash_msg = ""
print("Dotbot version %s%s" % (dotbot.__version__, hash_msg))
exit(0)
if options.super_quiet:
log.set_level(Level.WARNING) log.set_level(Level.WARNING)
if (options.quiet): if options.quiet:
log.set_level(Level.INFO) log.set_level(Level.INFO)
if (options.verbose): if options.verbose > 0:
log.set_level(Level.DEBUG) log.set_level(Level.DEBUG)
tasks = read_config(options.config_file[0])
if options.force_color and options.no_color:
log.error("`--force-color` and `--no-color` cannot both be provided")
exit(1)
elif options.force_color:
log.use_color(True)
elif options.no_color:
log.use_color(False)
else:
log.use_color(sys.stdout.isatty())
plugins = []
plugin_directories = list(options.plugin_dirs)
if not options.disable_built_in_plugins:
plugins.extend([Clean, Create, Link, Shell])
plugin_paths = []
for directory in plugin_directories:
for plugin_path in glob.glob(os.path.join(directory, "*.py")):
plugin_paths.append(plugin_path)
for plugin_path in options.plugins:
plugin_paths.append(plugin_path)
for plugin_path in plugin_paths:
abspath = os.path.abspath(plugin_path)
plugins.extend(module.load(abspath))
if not options.config_file:
log.error("No configuration file specified")
exit(1)
tasks = read_config(options.config_file)
if tasks is None:
log.warning("Configuration file is empty, no work to do")
tasks = []
if not isinstance(tasks, list): if not isinstance(tasks, list):
raise ReadingError('Configuration file must be a list of tasks') raise ReadingError("Configuration file must be a list of tasks")
dispatcher = Dispatcher(options.base_directory[0]) if options.base_directory:
base_directory = os.path.abspath(options.base_directory)
else:
# default to directory of config file
base_directory = os.path.dirname(os.path.abspath(options.config_file))
os.chdir(base_directory)
dispatcher = Dispatcher(
base_directory,
only=options.only,
skip=options.skip,
exit_on_failure=options.exit_on_failure,
options=options,
plugins=plugins,
)
success = dispatcher.dispatch(tasks) success = dispatcher.dispatch(tasks)
if success: if success:
log.info('\n==> All tasks executed successfully') log.info("\n==> All tasks executed successfully")
else: else:
raise DispatchError('\n==> Some tasks were not executed successfully') raise DispatchError("\n==> Some tasks were not executed successfully")
except (ReadingError, DispatchError) as e: except (ReadingError, DispatchError) as e:
log.error('%s' % e) log.error("%s" % e)
exit(1) exit(1)
except KeyboardInterrupt: except KeyboardInterrupt:
log.error('\n==> Operation aborted') log.error("\n==> Operation aborted")
exit(1) exit(1)

View file

@ -1,21 +1,31 @@
import json
import os.path
import yaml import yaml
from .util import string from .util import string
class ConfigReader(object):
class ConfigReader:
def __init__(self, config_file_path): def __init__(self, config_file_path):
self._config = self._read(config_file_path) self._config = self._read(config_file_path)
def _read(self, config_file_path): def _read(self, config_file_path):
try: try:
_, ext = os.path.splitext(config_file_path)
with open(config_file_path) as fin: with open(config_file_path) as fin:
if ext == ".json":
data = json.load(fin)
else:
data = yaml.safe_load(fin) data = yaml.safe_load(fin)
return data return data
except Exception as e: except Exception as e:
msg = string.indent_lines(str(e)) msg = string.indent_lines(str(e))
raise ReadingError('Could not read config file:\n%s' % msg) raise ReadingError("Could not read config file:\n%s" % msg)
def get_config(self): def get_config(self):
return self._config return self._config
class ReadingError(Exception): class ReadingError(Exception):
pass pass

33
dotbot/context.py Normal file
View file

@ -0,0 +1,33 @@
import copy
import os
from argparse import Namespace
class Context:
"""
Contextual data and information for plugins.
"""
def __init__(self, base_directory, options=Namespace()):
self._base_directory = base_directory
self._defaults = {}
self._options = options
pass
def set_base_directory(self, base_directory):
self._base_directory = base_directory
def base_directory(self, canonical_path=True):
base_directory = self._base_directory
if canonical_path:
base_directory = os.path.realpath(base_directory)
return base_directory
def set_defaults(self, defaults):
self._defaults = defaults
def defaults(self):
return copy.deepcopy(self._defaults)
def options(self):
return copy.deepcopy(self._options)

View file

@ -1,43 +1,78 @@
import os import os
from .executor import Executor from argparse import Namespace
from .context import Context
from .messenger import Messenger from .messenger import Messenger
from .plugin import Plugin
class Dispatcher(object):
def __init__(self, base_directory): class Dispatcher:
def __init__(
self,
base_directory,
only=None,
skip=None,
exit_on_failure=False,
options=Namespace(),
plugins=None,
):
self._log = Messenger() self._log = Messenger()
self._set_base_directory(base_directory) self._setup_context(base_directory, options)
self._load_plugins() plugins = plugins or []
self._plugins = [plugin(self._context) for plugin in plugins]
self._only = only
self._skip = skip
self._exit = exit_on_failure
def _set_base_directory(self, base_directory): def _setup_context(self, base_directory, options):
path = os.path.abspath(os.path.realpath( path = os.path.abspath(os.path.expanduser(base_directory))
os.path.expanduser(base_directory))) if not os.path.exists(path):
if os.path.exists(path): raise DispatchError("Nonexistent base directory")
self._base_directory = path self._context = Context(path, options)
else:
raise DispatchError('Nonexistent base directory')
def dispatch(self, tasks): def dispatch(self, tasks):
success = True success = True
for task in tasks: for task in tasks:
for action in task: for action in task:
if (
self._only is not None
and action not in self._only
or self._skip is not None
and action in self._skip
) and action != "defaults":
self._log.info("Skipping action %s" % action)
continue
handled = False handled = False
if action == "defaults":
self._context.set_defaults(task[action]) # replace, not update
handled = True
# keep going, let other plugins handle this if they want
for plugin in self._plugins: for plugin in self._plugins:
if plugin.can_handle(action): if plugin.can_handle(action):
try: try:
success &= plugin.handle(action, task[action]) local_success = plugin.handle(action, task[action])
if not local_success and self._exit:
# The action has failed exit
self._log.error("Action %s failed" % action)
return False
success &= local_success
handled = True handled = True
except Exception: except Exception as err:
self._log.error( self._log.error(
'An error was encountered while executing action %s' % "An error was encountered while executing action %s" % action
action) )
self._log.debug(err)
if self._exit:
# There was an execption exit
return False
if not handled: if not handled:
success = False success = False
self._log.error('Action %s not handled' % action) self._log.error("Action %s not handled" % action)
if self._exit:
# Invalid action exit
return False
return success return success
def _load_plugins(self):
self._plugins = [plugin(self._base_directory)
for plugin in Executor.__subclasses__()]
class DispatchError(Exception): class DispatchError(Exception):
pass pass

View file

@ -1,4 +0,0 @@
from .executor import Executor
from .linker import Linker
from .cleaner import Cleaner
from .commandrunner import CommandRunner

View file

@ -1,52 +0,0 @@
import os
from . import Executor
class Cleaner(Executor):
'''
Cleans broken symbolic links.
'''
_directive = 'clean'
def can_handle(self, directive):
return directive == self._directive
def handle(self, directive, data):
if directive != self._directive:
raise ValueError('Cleaner cannot handle directive %s' % directive)
return self._process_clean(data)
def _process_clean(self, targets):
success = True
for target in targets:
success &= self._clean(target)
if success:
self._log.info('All targets have been cleaned')
else:
self._log.error('Some targets were not successfully cleaned')
return success
def _clean(self, target):
'''
Cleans all the broken symbolic links in target that point to
a subdirectory of the base directory.
'''
if not os.path.isdir(os.path.expanduser(target)):
self._log.debug('Ignoring nonexistent directory %s' % target)
return True
for item in os.listdir(os.path.expanduser(target)):
path = os.path.join(os.path.expanduser(target), item)
if not os.path.exists(path) and os.path.islink(path):
if self._in_directory(path, self._base_directory):
self._log.lowinfo('Removing invalid link %s -> %s' %
(path, os.path.join(os.path.dirname(path), os.readlink(path))))
os.remove(path)
return True
def _in_directory(self, path, directory):
'''
Returns true if the path is in the directory.
'''
directory = os.path.join(os.path.realpath(directory), '')
path = os.path.realpath(path)
return os.path.commonprefix([path, directory]) == directory

View file

@ -1,53 +0,0 @@
import os, subprocess
from . import Executor
class CommandRunner(Executor):
'''
Run arbitrary shell commands.
'''
_directive = 'shell'
def can_handle(self, directive):
return directive == self._directive
def handle(self, directive, data):
if directive != self._directive:
raise ValueError('CommandRunner cannot handle directive %s' %
directive)
return self._process_commands(data)
def _process_commands(self, data):
success = True
with open(os.devnull, 'w') as devnull:
for item in data:
stdin = stdout = stderr = devnull
if isinstance(item, dict):
cmd = item['command']
msg = item.get('description', None)
if item.get('stdin', False) is True:
stdin = None
if item.get('stdout', False) is True:
stdout = None
if item.get('stderr', False) is True:
stderr = None
elif isinstance(item, list):
cmd = item[0]
msg = item[1] if len(item) > 1 else None
else:
cmd = item
msg = None
if msg is None:
self._log.lowinfo(cmd)
else:
self._log.lowinfo('%s [%s]' % (msg, cmd))
ret = subprocess.call(cmd, shell=True, stdin=stdin, stdout=stdout,
stderr=stderr, cwd=self._base_directory)
if ret != 0:
success = False
self._log.warning('Command [%s] failed' % cmd)
if success:
self._log.info('All commands have been executed')
else:
self._log.error('Some commands were not successfully executed')
return success

View file

@ -1,24 +0,0 @@
from ..messenger import Messenger
class Executor(object):
'''
Abstract base class for commands that process directives.
'''
def __init__(self, base_directory):
self._base_directory = base_directory
self._log = Messenger()
def can_handle(self, directive):
'''
Returns true if the Executor can handle the directive.
'''
raise NotImplementedError
def handle(self, directive, data):
'''
Executes the directive.
Returns true if the Executor successfully handled the directive.
'''
raise NotImplementedError

View file

@ -1,142 +0,0 @@
import os, shutil
from . import Executor
class Linker(Executor):
'''
Symbolically links dotfiles.
'''
_directive = 'link'
def can_handle(self, directive):
return directive == self._directive
def handle(self, directive, data):
if directive != self._directive:
raise ValueError('Linker cannot handle directive %s' % directive)
return self._process_links(data)
def _process_links(self, links):
success = True
for destination, source in links.items():
source = os.path.expandvars(source)
destination = os.path.expandvars(destination)
if isinstance(source, dict):
# extended config
path = source['path']
force = source.get('force', False)
relink = source.get('relink', False)
create = source.get('create', False)
if create:
success &= self._create(destination)
if force:
success &= self._delete(path, destination, force=True)
elif relink:
success &= self._delete(path, destination, force=False)
else:
path = source
success &= self._link(path, destination)
if success:
self._log.info('All links have been set up')
else:
self._log.error('Some links were not successfully set up')
return success
def _is_link(self, path):
'''
Returns true if the path is a symbolic link.
'''
return os.path.islink(os.path.expanduser(path))
def _link_destination(self, path):
'''
Returns the absolute path to the destination of the symbolic link.
'''
path = os.path.expanduser(path)
rel_dest = os.readlink(path)
return os.path.join(os.path.dirname(path), rel_dest)
def _exists(self, path):
'''
Returns true if the path exists.
'''
path = os.path.expanduser(path)
return os.path.exists(path)
def _create(self, path):
success = True
parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir))
if not self._exists(parent):
try:
os.makedirs(parent)
except OSError:
self._log.warning('Failed to create directory %s' % parent)
success = False
else:
self._log.lowinfo('Creating directory %s' % parent)
return success
def _delete(self, source, path, force):
success = True
source = os.path.join(self._base_directory, source)
if ((self._is_link(path) and self._link_destination(path) != source) or
(self._exists(path) and not self._is_link(path))):
fullpath = os.path.expanduser(path)
removed = False
try:
if os.path.islink(fullpath):
os.unlink(fullpath)
removed = True
elif force:
if os.path.isdir(fullpath):
shutil.rmtree(fullpath)
removed = True
else:
os.remove(fullpath)
removed = True
except OSError:
self._log.warning('Failed to remove %s' % path)
success = False
else:
if removed:
self._log.lowinfo('Removing %s' % path)
return success
def _link(self, source, link_name):
'''
Links link_name to source.
Returns true if successfully linked files.
'''
success = False
source = os.path.join(self._base_directory, source)
if (not self._exists(link_name) and self._is_link(link_name) and
self._link_destination(link_name) != source):
self._log.warning('Invalid link %s -> %s' %
(link_name, self._link_destination(link_name)))
elif not self._exists(link_name) and self._exists(source):
try:
os.symlink(source, os.path.expanduser(link_name))
except OSError:
self._log.warning('Linking failed %s -> %s' % (link_name, source))
else:
self._log.lowinfo('Creating link %s -> %s' % (link_name, source))
success = True
elif self._exists(link_name) and not self._is_link(link_name):
self._log.warning(
'%s already exists but is a regular file or directory' %
link_name)
elif self._is_link(link_name) and self._link_destination(link_name) != source:
self._log.warning('Incorrect link %s -> %s' %
(link_name, self._link_destination(link_name)))
elif not self._exists(source):
if self._is_link(link_name):
self._log.warning('Nonexistent target %s -> %s' %
(link_name, source))
else:
self._log.warning('Nonexistent target for %s : %s' %
(link_name, source))
else:
self._log.lowinfo('Link exists %s -> %s' % (link_name, source))
success = True
return success

View file

@ -1,2 +1,2 @@
from .messenger import Messenger
from .level import Level from .level import Level
from .messenger import Messenger

View file

@ -1,8 +1,8 @@
class Color(object): class Color:
NONE = '' NONE = ""
RESET = '\033[0m' RESET = "\033[0m"
RED = '\033[91m' RED = "\033[91m"
GREEN = '\033[92m' GREEN = "\033[92m"
YELLOW = '\033[93m' YELLOW = "\033[93m"
BLUE = '\033[94m' BLUE = "\033[94m"
MAGENTA = '\033[95m' MAGENTA = "\033[95m"

View file

@ -1,4 +1,4 @@
class Level(object): class Level:
NOTSET = 0 NOTSET = 0
DEBUG = 10 DEBUG = 10
LOWINFO = 15 LOWINFO = 15

View file

@ -1,19 +1,22 @@
import sys
from ..util.singleton import Singleton from ..util.singleton import Singleton
from ..util.compat import with_metaclass
from .color import Color from .color import Color
from .level import Level from .level import Level
class Messenger(with_metaclass(Singleton, object)):
def __init__(self, level = Level.LOWINFO): class Messenger(metaclass=Singleton):
def __init__(self, level=Level.LOWINFO):
self.set_level(level) self.set_level(level)
self.use_color(True)
def set_level(self, level): def set_level(self, level):
self._level = level self._level = level
def use_color(self, yesno):
self._use_color = yesno
def log(self, level, message): def log(self, level, message):
if (level >= self._level): if level >= self._level:
print('%s%s%s' % (self._color(level), message, self._reset())) print("%s%s%s" % (self._color(level), message, self._reset()))
def debug(self, message): def debug(self, message):
self.log(Level.DEBUG, message) self.log(Level.DEBUG, message)
@ -31,13 +34,13 @@ class Messenger(with_metaclass(Singleton, object)):
self.log(Level.ERROR, message) self.log(Level.ERROR, message)
def _color(self, level): def _color(self, level):
''' """
Get a color (terminal escape sequence) according to a level. Get a color (terminal escape sequence) according to a level.
''' """
if not sys.stdout.isatty(): if not self._use_color:
return '' return ""
elif level < Level.DEBUG: elif level < Level.DEBUG:
return '' return ""
elif Level.DEBUG <= level < Level.LOWINFO: elif Level.DEBUG <= level < Level.LOWINFO:
return Color.YELLOW return Color.YELLOW
elif Level.LOWINFO <= level < Level.INFO: elif Level.LOWINFO <= level < Level.INFO:
@ -50,10 +53,10 @@ class Messenger(with_metaclass(Singleton, object)):
return Color.RED return Color.RED
def _reset(self): def _reset(self):
''' """
Get a reset color (terminal escape sequence). Get a reset color (terminal escape sequence).
''' """
if not sys.stdout.isatty(): if not self._use_color:
return '' return ""
else: else:
return Color.RESET return Color.RESET

26
dotbot/plugin.py Normal file
View file

@ -0,0 +1,26 @@
from .context import Context
from .messenger import Messenger
class Plugin:
"""
Abstract base class for commands that process directives.
"""
def __init__(self, context):
self._context = context
self._log = Messenger()
def can_handle(self, directive):
"""
Returns true if the Plugin can handle the directive.
"""
raise NotImplementedError
def handle(self, directive, data):
"""
Executes the directive.
Returns true if the Plugin successfully handled the directive.
"""
raise NotImplementedError

View file

@ -0,0 +1,4 @@
from .clean import Clean
from .create import Create
from .link import Link
from .shell import Shell

72
dotbot/plugins/clean.py Normal file
View file

@ -0,0 +1,72 @@
import os
import sys
from ..plugin import Plugin
class Clean(Plugin):
"""
Cleans broken symbolic links.
"""
_directive = "clean"
def can_handle(self, directive):
return directive == self._directive
def handle(self, directive, data):
if directive != self._directive:
raise ValueError("Clean cannot handle directive %s" % directive)
return self._process_clean(data)
def _process_clean(self, targets):
success = True
defaults = self._context.defaults().get(self._directive, {})
for target in targets:
force = defaults.get("force", False)
recursive = defaults.get("recursive", False)
if isinstance(targets, dict) and isinstance(targets[target], dict):
force = targets[target].get("force", force)
recursive = targets[target].get("recursive", recursive)
success &= self._clean(target, force, recursive)
if success:
self._log.info("All targets have been cleaned")
else:
self._log.error("Some targets were not successfully cleaned")
return success
def _clean(self, target, force, recursive):
"""
Cleans all the broken symbolic links in target if they point to
a subdirectory of the base directory or if forced to clean.
"""
if not os.path.isdir(os.path.expandvars(os.path.expanduser(target))):
self._log.debug("Ignoring nonexistent directory %s" % target)
return True
for item in os.listdir(os.path.expandvars(os.path.expanduser(target))):
path = os.path.abspath(
os.path.join(os.path.expandvars(os.path.expanduser(target)), item)
)
if recursive and os.path.isdir(path):
# isdir implies not islink -- we don't want to descend into
# symlinked directories. okay to do a recursive call here
# because depth should be fairly limited
self._clean(path, force, recursive)
if not os.path.exists(path) and os.path.islink(path):
points_at = os.path.join(os.path.dirname(path), os.readlink(path))
if sys.platform[:5] == "win32" and points_at.startswith("\\\\?\\"):
points_at = points_at[4:]
if self._in_directory(path, self._context.base_directory()) or force:
self._log.lowinfo("Removing invalid link %s -> %s" % (path, points_at))
os.remove(path)
else:
self._log.lowinfo("Link %s -> %s not removed." % (path, points_at))
return True
def _in_directory(self, path, directory):
"""
Returns true if the path is in the directory.
"""
directory = os.path.join(os.path.realpath(directory), "")
path = os.path.realpath(path)
return os.path.commonprefix([path, directory]) == directory

60
dotbot/plugins/create.py Normal file
View file

@ -0,0 +1,60 @@
import os
from ..plugin import Plugin
class Create(Plugin):
"""
Create empty paths.
"""
_directive = "create"
def can_handle(self, directive):
return directive == self._directive
def handle(self, directive, data):
if directive != self._directive:
raise ValueError("Create cannot handle directive %s" % directive)
return self._process_paths(data)
def _process_paths(self, paths):
success = True
defaults = self._context.defaults().get("create", {})
for key in paths:
path = os.path.abspath(os.path.expandvars(os.path.expanduser(key)))
mode = defaults.get("mode", 0o777) # same as the default for os.makedirs
if isinstance(paths, dict):
options = paths[key]
if options:
mode = options.get("mode", mode)
success &= self._create(path, mode)
if success:
self._log.info("All paths have been set up")
else:
self._log.error("Some paths were not successfully set up")
return success
def _exists(self, path):
"""
Returns true if the path exists.
"""
path = os.path.expanduser(path)
return os.path.exists(path)
def _create(self, path, mode):
success = True
if not self._exists(path):
self._log.debug("Trying to create path %s with mode %o" % (path, mode))
try:
self._log.lowinfo("Creating path %s" % path)
os.makedirs(path, mode)
# On Windows, the *mode* argument to `os.makedirs()` is ignored.
# The mode must be set explicitly in a follow-up call.
os.chmod(path, mode)
except OSError:
self._log.warning("Failed to create path %s" % path)
success = False
else:
self._log.lowinfo("Path exists %s" % path)
return success

286
dotbot/plugins/link.py Normal file
View file

@ -0,0 +1,286 @@
import glob
import os
import shutil
import sys
from ..plugin import Plugin
from ..util import shell_command
class Link(Plugin):
"""
Symbolically links dotfiles.
"""
_directive = "link"
def can_handle(self, directive):
return directive == self._directive
def handle(self, directive, data):
if directive != self._directive:
raise ValueError("Link cannot handle directive %s" % directive)
return self._process_links(data)
def _process_links(self, links):
success = True
defaults = self._context.defaults().get("link", {})
for destination, source in links.items():
destination = os.path.expandvars(destination)
relative = defaults.get("relative", False)
# support old "canonicalize-path" key for compatibility
canonical_path = defaults.get("canonicalize", defaults.get("canonicalize-path", True))
force = defaults.get("force", False)
relink = defaults.get("relink", False)
create = defaults.get("create", False)
use_glob = defaults.get("glob", False)
base_prefix = defaults.get("prefix", "")
test = defaults.get("if", None)
ignore_missing = defaults.get("ignore-missing", False)
exclude_paths = defaults.get("exclude", [])
if isinstance(source, dict):
# extended config
test = source.get("if", test)
relative = source.get("relative", relative)
canonical_path = source.get(
"canonicalize", source.get("canonicalize-path", canonical_path)
)
force = source.get("force", force)
relink = source.get("relink", relink)
create = source.get("create", create)
use_glob = source.get("glob", use_glob)
base_prefix = source.get("prefix", base_prefix)
ignore_missing = source.get("ignore-missing", ignore_missing)
exclude_paths = source.get("exclude", exclude_paths)
path = self._default_source(destination, source.get("path"))
else:
path = self._default_source(destination, source)
if test is not None and not self._test_success(test):
self._log.lowinfo("Skipping %s" % destination)
continue
path = os.path.normpath(os.path.expandvars(os.path.expanduser(path)))
if use_glob and self._has_glob_chars(path):
glob_results = self._create_glob_results(path, exclude_paths)
self._log.lowinfo("Globs from '" + path + "': " + str(glob_results))
for glob_full_item in glob_results:
# Find common dirname between pattern and the item:
glob_dirname = os.path.dirname(os.path.commonprefix([path, glob_full_item]))
glob_item = (
glob_full_item
if len(glob_dirname) == 0
else glob_full_item[len(glob_dirname) + 1 :]
)
# Add prefix to basepath, if provided
if base_prefix:
glob_item = base_prefix + glob_item
# where is it going
glob_link_destination = os.path.join(destination, glob_item)
if create:
success &= self._create(glob_link_destination)
if force or relink:
success &= self._delete(
glob_full_item,
glob_link_destination,
relative,
canonical_path,
force,
)
success &= self._link(
glob_full_item,
glob_link_destination,
relative,
canonical_path,
ignore_missing,
)
else:
if create:
success &= self._create(destination)
if not ignore_missing and not self._exists(
os.path.join(self._context.base_directory(), path)
):
# we seemingly check this twice (here and in _link) because
# if the file doesn't exist and force is True, we don't
# want to remove the original (this is tested by
# link-force-leaves-when-nonexistent.bash)
success = False
self._log.warning("Nonexistent source %s -> %s" % (destination, path))
continue
if force or relink:
success &= self._delete(path, destination, relative, canonical_path, force)
success &= self._link(path, destination, relative, canonical_path, ignore_missing)
if success:
self._log.info("All links have been set up")
else:
self._log.error("Some links were not successfully set up")
return success
def _test_success(self, command):
ret = shell_command(command, cwd=self._context.base_directory())
if ret != 0:
self._log.debug("Test '%s' returned false" % command)
return ret == 0
def _default_source(self, destination, source):
if source is None:
basename = os.path.basename(destination)
if basename.startswith("."):
return basename[1:]
else:
return basename
else:
return source
def _has_glob_chars(self, path):
return any(i in path for i in "?*[")
def _glob(self, path):
"""
Wrap `glob.glob` in a python agnostic way, catching errors in usage.
"""
found = glob.glob(path, recursive=True)
# normalize paths to ensure cross-platform compatibility
found = [os.path.normpath(p) for p in found]
# if using recursive glob (`**`), filter results to return only files:
if "**" in path and not path.endswith(str(os.sep)):
self._log.debug("Excluding directories from recursive glob: " + str(path))
found = [f for f in found if os.path.isfile(f)]
# return matched results
return found
def _create_glob_results(self, path, exclude_paths):
self._log.debug("Globbing with pattern: " + str(path))
include = self._glob(path)
self._log.debug("Glob found : " + str(include))
# filter out any paths matching the exclude globs:
exclude = []
for expat in exclude_paths:
self._log.debug("Excluding globs with pattern: " + str(expat))
exclude.extend(self._glob(expat))
self._log.debug("Excluded globs from '" + path + "': " + str(exclude))
ret = set(include) - set(exclude)
return list(ret)
def _is_link(self, path):
"""
Returns true if the path is a symbolic link.
"""
return os.path.islink(os.path.expanduser(path))
def _link_destination(self, path):
"""
Returns the destination of the symbolic link.
"""
path = os.path.expanduser(path)
path = os.readlink(path)
if sys.platform[:5] == "win32" and path.startswith("\\\\?\\"):
path = path[4:]
return path
def _exists(self, path):
"""
Returns true if the path exists.
"""
path = os.path.expanduser(path)
return os.path.exists(path)
def _create(self, path):
success = True
parent = os.path.abspath(os.path.join(os.path.expanduser(path), os.pardir))
if not self._exists(parent):
self._log.debug("Try to create parent: " + str(parent))
try:
os.makedirs(parent)
except OSError:
self._log.warning("Failed to create directory %s" % parent)
success = False
else:
self._log.lowinfo("Creating directory %s" % parent)
return success
def _delete(self, source, path, relative, canonical_path, force):
success = True
source = os.path.join(self._context.base_directory(canonical_path=canonical_path), source)
fullpath = os.path.abspath(os.path.expanduser(path))
if relative:
source = self._relative_path(source, fullpath)
if (self._is_link(path) and self._link_destination(path) != source) or (
self._exists(path) and not self._is_link(path)
):
removed = False
try:
if os.path.islink(fullpath):
os.unlink(fullpath)
removed = True
elif force:
if os.path.isdir(fullpath):
shutil.rmtree(fullpath)
removed = True
else:
os.remove(fullpath)
removed = True
except OSError:
self._log.warning("Failed to remove %s" % path)
success = False
else:
if removed:
self._log.lowinfo("Removing %s" % path)
return success
def _relative_path(self, source, destination):
"""
Returns the relative path to get to the source file from the
destination file.
"""
destination_dir = os.path.dirname(destination)
return os.path.relpath(source, destination_dir)
def _link(self, source, link_name, relative, canonical_path, ignore_missing):
"""
Links link_name to source.
Returns true if successfully linked files.
"""
success = False
destination = os.path.abspath(os.path.expanduser(link_name))
base_directory = self._context.base_directory(canonical_path=canonical_path)
absolute_source = os.path.join(base_directory, source)
link_name = os.path.normpath(link_name)
if relative:
source = self._relative_path(absolute_source, destination)
else:
source = absolute_source
if (
not self._exists(link_name)
and self._is_link(link_name)
and self._link_destination(link_name) != source
):
self._log.warning(
"Invalid link %s -> %s" % (link_name, self._link_destination(link_name))
)
# we need to use absolute_source below because our cwd is the dotfiles
# directory, and if source is relative, it will be relative to the
# destination directory
elif not self._exists(link_name) and (ignore_missing or self._exists(absolute_source)):
try:
os.symlink(source, destination)
except OSError:
self._log.warning("Linking failed %s -> %s" % (link_name, source))
else:
self._log.lowinfo("Creating link %s -> %s" % (link_name, source))
success = True
elif self._exists(link_name) and not self._is_link(link_name):
self._log.warning("%s already exists but is a regular file or directory" % link_name)
elif self._is_link(link_name) and self._link_destination(link_name) != source:
self._log.warning(
"Incorrect link %s -> %s" % (link_name, self._link_destination(link_name))
)
# again, we use absolute_source to check for existence
elif not self._exists(absolute_source):
if self._is_link(link_name):
self._log.warning("Nonexistent source %s -> %s" % (link_name, source))
else:
self._log.warning("Nonexistent source for %s : %s" % (link_name, source))
else:
self._log.lowinfo("Link exists %s -> %s" % (link_name, source))
success = True
return success

77
dotbot/plugins/shell.py Normal file
View file

@ -0,0 +1,77 @@
from ..plugin import Plugin
from ..util import shell_command
class Shell(Plugin):
"""
Run arbitrary shell commands.
"""
_directive = "shell"
_has_shown_override_message = False
def can_handle(self, directive):
return directive == self._directive
def handle(self, directive, data):
if directive != self._directive:
raise ValueError("Shell cannot handle directive %s" % directive)
return self._process_commands(data)
def _process_commands(self, data):
success = True
defaults = self._context.defaults().get("shell", {})
options = self._get_option_overrides()
for item in data:
stdin = defaults.get("stdin", False)
stdout = defaults.get("stdout", False)
stderr = defaults.get("stderr", False)
quiet = defaults.get("quiet", False)
if isinstance(item, dict):
cmd = item["command"]
msg = item.get("description", None)
stdin = item.get("stdin", stdin)
stdout = item.get("stdout", stdout)
stderr = item.get("stderr", stderr)
quiet = item.get("quiet", quiet)
elif isinstance(item, list):
cmd = item[0]
msg = item[1] if len(item) > 1 else None
else:
cmd = item
msg = None
if quiet:
if msg is not None:
self._log.lowinfo("%s" % msg)
elif msg is None:
self._log.lowinfo(cmd)
else:
self._log.lowinfo("%s [%s]" % (msg, cmd))
stdout = options.get("stdout", stdout)
stderr = options.get("stderr", stderr)
ret = shell_command(
cmd,
cwd=self._context.base_directory(),
enable_stdin=stdin,
enable_stdout=stdout,
enable_stderr=stderr,
)
if ret != 0:
success = False
self._log.warning("Command [%s] failed" % cmd)
if success:
self._log.info("All commands have been executed")
else:
self._log.error("Some commands were not successfully executed")
return success
def _get_option_overrides(self):
ret = {}
options = self._context.options()
if options.verbose > 1:
ret["stderr"] = True
ret["stdout"] = True
if not self._has_shown_override_message:
self._log.debug("Shell: Found cli option to force show stderr and stdout.")
self._has_shown_override_message = True
return ret

View file

@ -0,0 +1 @@
from .common import shell_command

34
dotbot/util/common.py Normal file
View file

@ -0,0 +1,34 @@
import os
import platform
import subprocess
def shell_command(command, cwd=None, enable_stdin=False, enable_stdout=False, enable_stderr=False):
with open(os.devnull, "w") as devnull_w, open(os.devnull, "r") as devnull_r:
stdin = None if enable_stdin else devnull_r
stdout = None if enable_stdout else devnull_w
stderr = None if enable_stderr else devnull_w
executable = os.environ.get("SHELL")
if platform.system() == "Windows":
# We avoid setting the executable kwarg on Windows because it does
# not have the desired effect when combined with shell=True. It
# will result in the correct program being run (e.g. bash), but it
# will be invoked with a '/c' argument instead of a '-c' argument,
# which it won't understand.
#
# See https://github.com/anishathalye/dotbot/issues/219 and
# https://bugs.python.org/issue40467.
#
# This means that complex commands that require Bash's parsing
# won't work; a workaround for this is to write the command as
# `bash -c "..."`.
executable = None
return subprocess.call(
command,
shell=True,
executable=executable,
stdin=stdin,
stdout=stdout,
stderr=stderr,
cwd=cwd,
)

View file

@ -1,5 +0,0 @@
def with_metaclass(meta, *bases):
class metaclass(meta):
def __new__(cls, name, this_bases, d):
return meta(name, bases, d)
return type.__new__(metaclass, 'temporary_class', (), {})

33
dotbot/util/module.py Normal file
View file

@ -0,0 +1,33 @@
import os
import sys
from dotbot.plugin import Plugin
# We keep references to loaded modules so they don't get garbage collected.
loaded_modules = []
def load(path):
basename = os.path.basename(path)
module_name, extension = os.path.splitext(basename)
loaded_module = load_module(module_name, path)
plugins = []
for name in dir(loaded_module):
possible_plugin = getattr(loaded_module, name)
try:
if issubclass(possible_plugin, Plugin) and possible_plugin is not Plugin:
plugins.append(possible_plugin)
except TypeError:
pass
loaded_modules.append(loaded_module)
return plugins
import importlib.util
def load_module(module_name, path):
spec = importlib.util.spec_from_file_location(module_name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module

View file

@ -1,5 +1,6 @@
class Singleton(type): class Singleton(type):
_instances = {} _instances = {}
def __call__(cls, *args, **kwargs): def __call__(cls, *args, **kwargs):
if cls not in cls._instances: if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)

View file

@ -1,4 +1,4 @@
def indent_lines(string, amount=2, delimiter='\n'): def indent_lines(string, amount=2, delimiter="\n"):
whitespace = ' ' * amount whitespace = " " * amount
sep = '%s%s' % (delimiter, whitespace) sep = "%s%s" % (delimiter, whitespace)
return '%s%s' % (whitespace, sep.join(string.split(delimiter))) return "%s%s" % (whitespace, sep.join(string.split(delimiter)))

@ -1 +1 @@
Subproject commit f30c956c11aa6b5e7827fe5840cc9ed40b938d17 Subproject commit c42fa3bff1eabdb64763bb1526d9ea1ccb708479

17
pyproject.toml Normal file
View file

@ -0,0 +1,17 @@
[tool.black]
line-length = 100
exclude = '''
/(
\.git
| \.github
| .*\.egg-info
| build
| dist
| lib
)/
'''
[tool.pytest.ini_options]
filterwarnings = [
"error",
]

73
setup.py Normal file
View file

@ -0,0 +1,73 @@
import re
from os import path
from setuptools import find_packages, setup
here = path.dirname(__file__)
with open(path.join(here, "README.md"), encoding="utf-8") as f:
long_description = f.read()
def read(*names, **kwargs):
with open(path.join(here, *names), encoding=kwargs.get("encoding", "utf8")) as fp:
return fp.read()
def find_version(*file_paths):
version_file = read(*file_paths)
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
if version_match:
return version_match.group(1)
raise RuntimeError("Unable to find version string.")
setup(
name="dotbot",
version=find_version("dotbot", "__init__.py"),
description="A tool that bootstraps your dotfiles",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/anishathalye/dotbot",
author="Anish Athalye",
author_email="me@anishathalye.com",
license="MIT",
classifiers=[
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
],
keywords="dotfiles",
packages=find_packages(),
setup_requires=[
"setuptools>=38.6.0",
"wheel>=0.31.0",
],
install_requires=[
"PyYAML>=6.0.1,<7",
],
extras_require={
"dev": {
"pytest",
"tox",
}
},
# To provide executable scripts, use entry points in preference to the
# "scripts" keyword. Entry points provide cross-platform support and allow
# pip to create the appropriate form of executable for the target platform.
entry_points={
"console_scripts": [
"dotbot=dotbot:main",
],
},
)

View file

@ -1,5 +0,0 @@
[Vagrantfile]
indent_size = 2
[test]
indent_size = 4

1
test/.gitignore vendored
View file

@ -1 +0,0 @@
.vagrant/

View file

@ -1,24 +0,0 @@
Testing
=======
Dotbot testing code uses [Vagrant][vagrant] to run all tests inside a virtual
machine to have tests be completely isolated from the host machine. The test
driver relies on the [Sahara][sahara] plugin to snapshot and roll back virtual
machine state. The tests are deterministic, and each test is run in a virtual
machine with fresh state, ensuring that tests that modify system state are
easily repeatable.
Running the Tests
-----------------
Before running the tests, the virtual machine must be running. It can be
started by running `vagrant up`.
The test suite can be run by running `./test`. Selected tests can be run by
passing paths to the tests as arguments to `./test`.
When finished with testing, it is good to shut down the virtual machine by
running `vagrant halt`.
[vagrant]: https://www.vagrantup.com/
[sahara]: https://github.com/jedi4ever/sahara

10
test/Vagrantfile vendored
View file

@ -1,10 +0,0 @@
Vagrant.configure(2) do |config|
config.vm.box = 'ubuntu/trusty64'
# sync by copying for isolation
config.vm.synced_folder "..", "/dotbot", type: "rsync",
rsync__exclude: ".git/"
# disable default synced folder
config.vm.synced_folder ".", "/vagrant", disabled: true
end

View file

@ -1,120 +0,0 @@
MAXRETRY=5
TIMEOUT=1
red() {
if [ -t 1 ]; then
printf "\033[31m%s\033[0m\n" "$*"
else
printf "%s\n" "$*"
fi
}
green() {
if [ -t 1 ]; then
printf "\033[32m%s\033[0m\n" "$*"
else
printf "%s\n" "$*"
fi
}
yellow() {
if [ -t 1 ]; then
printf "\033[33m%s\033[0m\n" "$*"
else
printf "%s\n" "$*"
fi
}
check_prereqs() {
if ! (vagrant ssh -c 'exit') >/dev/null 2>&1; then
>&2 echo "vagrant vm must be running."
return 1
fi
if ! (vagrant plugin list | grep '^sahara\s\+') >/dev/null 2>&1; then
>&2 echo "vagrant plugin 'sahara' is not installed."
return 1
fi
}
until_success() {
local timeout=${TIMEOUT}
local attempt=0
while [ $attempt -lt $MAXRETRY ]; do
if ($@) >/dev/null 2>&1; then
return 0
fi
sleep $timeout
timeout=$((timeout * 2))
attempt=$((attempt + 1))
done
return 1
}
wait_for_vagrant() {
until_success vagrant ssh -c 'exit'
}
rollback() {
vagrant sandbox rollback >/dev/null 2>&1 &&
wait_for_vagrant &&
vagrant rsync >/dev/null 2>&1
}
initialize() {
echo "initializing."
vagrant sandbox on >/dev/null 2>&1
tests_run=0
tests_passed=0
tests_failed=0
tests_total="${1}"
local plural="" && [ "${tests_total}" -gt 1 ] && plural="s"
printf -- "running %d test%s...\n\n" "${tests_total}" "${plural}"
}
pass() {
tests_passed=$((tests_passed + 1))
green "-> ok."
echo
}
fail() {
tests_failed=$((tests_failed + 1))
yellow "-> fail!"
echo
}
run_test() {
tests_run=$((tests_run + 1))
printf '[%d/%d] (%s)\n' "${tests_run}" "${tests_total}" "${1}"
rollback || die "unable to rollback vm." # start with a clean slate
if vagrant ssh -c "cd /dotbot/test/tests && bash ${1}" 2>/dev/null; then
pass
else
fail
fi
}
report() {
printf -- "test report\n"
printf -- "-----------\n"
printf -- "- %3d run\n" ${tests_run}
printf -- "- %3d passed\n" ${tests_passed}
if [ ${tests_failed} -gt 0 ]; then
printf -- "- %3d failed\n" ${tests_failed}
echo
red "==> not ok!"
return 1
else
echo
green "==> all ok."
return 0
fi
}
die() {
>&2 echo $@
>&2 echo "terminating..."
exit 1
}

View file

@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -e
BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${BASEDIR}"
. "./driver-lib.bash"
start="$(date +%s)"
check_prereqs || die "prerequisites unsatsfied."
declare -a tests=()
if [ $# -eq 0 ]; then
while read file; do
tests+=("${file}")
done < <(find tests -type f -name '*.bash')
else
tests=("$@")
fi
initialize "${#tests[@]}"
for file in "${tests[@]}"; do
run_test "$(basename "${file}")"
done
if report; then
ret=0
else
ret=1
fi
echo "(tests run in $(($(date +%s) - start)) seconds)"
exit ${ret}

View file

@ -1,51 +0,0 @@
DEBUG=false
DOTFILES='/home/vagrant/dotfiles'
INSTALL_CONF='install.conf.yaml'
test_run_() {
if ! ${DEBUG}; then
(eval "$*") >/dev/null 2>&1
else
(eval "$*")
fi
}
test_expect_success() {
local tag=${1} && shift
if ! test_run_ "$@"; then
>&2 echo "- ${tag} failed."
exit 1
fi
}
test_expect_failure() {
local tag=${1} && shift
if test_run_ "$@"; then
>&2 echo "- ${tag} failed."
exit 1
fi
}
check_vm() {
if [ "$(whoami)" != "vagrant" ]; then
>&2 echo "test can't run outside vm!"
exit 1
fi
}
initialize() {
check_vm
echo "${test_description}"
mkdir -p "${DOTFILES}"
cd
}
run_dotbot() {
(
cd "${DOTFILES}"
cat > "${INSTALL_CONF}"
/dotbot/bin/dotbot -d . -c "${INSTALL_CONF}" "${@}"
)
}
initialize

View file

@ -1,19 +0,0 @@
test_description='clean deletes links to missing files'
. '../test-lib.bash'
test_expect_success 'setup' '
touch ${DOTFILES}/f &&
ln -s ${DOTFILES}/f ~/.f &&
ln -s ${DOTFILES}/g ~/.g
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["~"]
EOF
'
test_expect_success 'test' '
test -f ~/.f &&
! test -h ~/.g
'

View file

@ -1,8 +0,0 @@
test_description='clean ignores nonexistent directories'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["~", "~/fake"]
EOF
'

View file

@ -1,18 +0,0 @@
test_description='clean ignores files linking outside dotfiles directory'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s ${DOTFILES}/f ~/.f &&
ln -s ~/g ~/.g
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["~"]
EOF
'
test_expect_success 'test' '
! test -h ~/.f &&
test -h ~/.g
'

View file

@ -1,8 +0,0 @@
test_description='blank config allowed'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot <<EOF
[]
EOF
'

View file

@ -1,7 +0,0 @@
test_description='empty config disallowed'
. '../test-lib.bash'
test_expect_failure 'run' '
run_dotbot <<EOF
EOF
'

View file

@ -1,18 +0,0 @@
test_description='link expands environment variables in source'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
export APPLE="h" &&
run_dotbot <<EOF
- link:
~/.i: \$APPLE
EOF
'
test_expect_success 'test' '
grep "grape" ~/.i
'

View file

@ -1,25 +0,0 @@
test_description='link expands environment variables in target'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
export ORANGE=".config" &&
export BANANA="g" &&
unset PEAR &&
run_dotbot <<EOF
- link:
~/\${ORANGE}/\$BANANA:
path: f
create: true
~/\$PEAR: h
EOF
'
test_expect_success 'test' '
grep "apple" ~/.config/g &&
grep "grape" ~/\$PEAR
'

View file

@ -1,18 +0,0 @@
test_description='link leaves unset environment variables'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/\$ORANGE
'
test_expect_success 'run' '
unset ORANGE &&
run_dotbot <<EOF
- link:
~/.f: \$ORANGE
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f
'

View file

@ -1,21 +0,0 @@
test_description='force overwrites symlinked directory'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/dir ~/dir &&
touch ${DOTFILES}/dir/f &&
ln -s ~/ ~/.dir
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.dir:
path: dir
force: true
EOF
'
test_expect_success 'test' '
test -f ~/.dir/f
'

View file

@ -1,18 +0,0 @@
test_description='relink does not overwrite file'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/.f
'
test_expect_failure 'run' '
run_dotbot <<EOF
- link:
~/.f: f
EOF
'
test_expect_success 'test' '
grep "grape" ~/.f
'

View file

@ -1,20 +0,0 @@
test_description='relink does not overwrite file'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/.f
'
test_expect_failure 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
relink: true
EOF
'
test_expect_success 'test' '
grep "grape" ~/.f
'

View file

@ -1,21 +0,0 @@
test_description='relink overwrites symlink'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/f &&
ln -s ~/f ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
relink: true
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f
'

View file

@ -1,11 +0,0 @@
test_description='shell command stdout works'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | grep "^apple") <<EOF
- shell:
-
command: echo apple
stdout: true
EOF
'

View file

@ -1,9 +0,0 @@
test_description='shell command stdout disabled by default'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | (! grep "^banana")) <<EOF
- shell:
- echo banana
EOF
'

317
tests/conftest.py Normal file
View file

@ -0,0 +1,317 @@
import builtins
import ctypes
import json
import os
import shutil
import sys
import tempfile
import unittest.mock as mock
from shutil import rmtree
import pytest
import yaml
import dotbot.cli
def get_long_path(path):
"""Get the long path for a given path."""
# Do nothing for non-Windows platforms.
if sys.platform[:5] != "win32":
return path
buffer_size = 1000
buffer = ctypes.create_unicode_buffer(buffer_size)
get_long_path_name = ctypes.windll.kernel32.GetLongPathNameW
get_long_path_name(path, buffer, buffer_size)
return buffer.value
# On Linux, tempfile.TemporaryFile() requires unlink access.
# This list is updated by a tempfile._mkstemp_inner() wrapper,
# and its contents are checked by wrapped functions.
allowed_tempfile_internal_unlink_calls = []
def wrap_function(function, function_path, arg_index, kwarg_key, root):
def wrapper(*args, **kwargs):
if kwarg_key in kwargs:
value = kwargs[kwarg_key]
else:
value = args[arg_index]
# Allow tempfile.TemporaryFile's internal unlink calls to work.
if value in allowed_tempfile_internal_unlink_calls:
return function(*args, **kwargs)
msg = "The '{0}' argument to {1}() must be an absolute path"
msg = msg.format(kwarg_key, function_path)
assert value == os.path.abspath(value), msg
msg = "The '{0}' argument to {1}() must be rooted in {2}"
msg = msg.format(kwarg_key, function_path, root)
assert value[: len(str(root))] == str(root), msg
return function(*args, **kwargs)
return wrapper
def wrap_open(root):
wrapped = getattr(builtins, "open")
def wrapper(*args, **kwargs):
if "file" in kwargs:
value = kwargs["file"]
else:
value = args[0]
mode = "r"
if "mode" in kwargs:
mode = kwargs["mode"]
elif len(args) >= 2:
mode = args[1]
msg = "The 'file' argument to open() must be an absolute path"
if value != os.devnull and "w" in mode:
assert value == os.path.abspath(value), msg
msg = "The 'file' argument to open() must be rooted in {0}"
msg = msg.format(root)
if value != os.devnull and "w" in mode:
assert value[: len(str(root))] == str(root), msg
return wrapped(*args, **kwargs)
return wrapper
def rmtree_error_handler(_, path, __):
# Handle read-only files and directories.
os.chmod(path, 0o777)
if os.path.isdir(path):
rmtree(path)
else:
os.unlink(path)
@pytest.fixture(autouse=True, scope="session")
def standardize_tmp():
r"""Standardize the temporary directory path.
On MacOS, `/var` is a symlink to `/private/var`.
This creates issues with link canonicalization and relative link tests,
so this fixture rewrites environment variables and Python variables
to ensure the tests work the same as on Linux and Windows.
On Windows in GitHub CI, the temporary directory may be a short path.
For example, `C:\Users\RUNNER~1\...` instead of `C:\Users\runneradmin\...`.
This causes string-based path comparisons to fail.
"""
tmp = tempfile.gettempdir()
# MacOS: `/var` is a symlink.
tmp = os.path.abspath(os.path.realpath(tmp))
# Windows: The temporary directory may be a short path.
if sys.platform[:5] == "win32":
tmp = get_long_path(tmp)
os.environ["TMP"] = tmp
os.environ["TEMP"] = tmp
os.environ["TMPDIR"] = tmp
tempfile.tempdir = tmp
yield
@pytest.fixture(autouse=True)
def root(standardize_tmp):
"""Create a temporary directory for the duration of each test."""
# Reset allowed_tempfile_internal_unlink_calls.
global allowed_tempfile_internal_unlink_calls
allowed_tempfile_internal_unlink_calls = []
# Dotbot changes the current working directory,
# so this must be reset at the end of each test.
current_working_directory = os.getcwd()
# Create an isolated temporary directory from which to operate.
current_root = tempfile.mkdtemp()
functions_to_wrap = [
(os, "chflags", 0, "path"),
(os, "chmod", 0, "path"),
(os, "chown", 0, "path"),
(os, "copy_file_range", 1, "dst"),
(os, "lchflags", 0, "path"),
(os, "lchmod", 0, "path"),
(os, "link", 1, "dst"),
(os, "makedirs", 0, "name"),
(os, "mkdir", 0, "path"),
(os, "mkfifo", 0, "path"),
(os, "mknod", 0, "path"),
(os, "remove", 0, "path"),
(os, "removedirs", 0, "name"),
(os, "removexattr", 0, "path"),
(os, "rename", 0, "src"), # Check both
(os, "rename", 1, "dst"),
(os, "renames", 0, "old"), # Check both
(os, "renames", 1, "new"),
(os, "replace", 0, "src"), # Check both
(os, "replace", 1, "dst"),
(os, "rmdir", 0, "path"),
(os, "setxattr", 0, "path"),
(os, "splice", 1, "dst"),
(os, "symlink", 1, "dst"),
(os, "truncate", 0, "path"),
(os, "unlink", 0, "path"),
(os, "utime", 0, "path"),
(shutil, "chown", 0, "path"),
(shutil, "copy", 1, "dst"),
(shutil, "copy2", 1, "dst"),
(shutil, "copyfile", 1, "dst"),
(shutil, "copymode", 1, "dst"),
(shutil, "copystat", 1, "dst"),
(shutil, "copytree", 1, "dst"),
(shutil, "make_archive", 0, "base_name"),
(shutil, "move", 0, "src"), # Check both
(shutil, "move", 1, "dst"),
(shutil, "rmtree", 0, "path"),
(shutil, "unpack_archive", 1, "extract_dir"),
]
patches = []
for module, function_name, arg_index, kwarg_key in functions_to_wrap:
# Skip anything that doesn't exist in this version of Python.
if not hasattr(module, function_name):
continue
# These values must be passed to a separate function
# to ensure the variable closures work correctly.
function_path = "{0}.{1}".format(module.__name__, function_name)
function = getattr(module, function_name)
wrapped = wrap_function(function, function_path, arg_index, kwarg_key, current_root)
patches.append(mock.patch(function_path, wrapped))
# open() must be separately wrapped.
function_path = "builtins.open"
wrapped = wrap_open(current_root)
patches.append(mock.patch(function_path, wrapped))
# Block all access to bad functions.
if hasattr(os, "chroot"):
patches.append(mock.patch("os.chroot", lambda *_, **__: None))
# Patch tempfile._mkstemp_inner() so tempfile.TemporaryFile()
# can unlink files immediately.
mkstemp_inner = tempfile._mkstemp_inner
def wrap_mkstemp_inner(*args, **kwargs):
(fd, name) = mkstemp_inner(*args, **kwargs)
allowed_tempfile_internal_unlink_calls.append(name)
return fd, name
patches.append(mock.patch("tempfile._mkstemp_inner", wrap_mkstemp_inner))
[patch.start() for patch in patches]
try:
yield current_root
finally:
[patch.stop() for patch in patches]
os.chdir(current_working_directory)
if sys.version_info >= (3, 12):
rmtree(current_root, onexc=rmtree_error_handler)
else:
rmtree(current_root, onerror=rmtree_error_handler)
@pytest.fixture
def home(monkeypatch, root):
"""Create a home directory for the duration of the test.
On *nix, the environment variable "HOME" will be mocked.
On Windows, the environment variable "USERPROFILE" will be mocked.
"""
home = os.path.abspath(os.path.join(root, "home/user"))
os.makedirs(home)
if sys.platform[:5] == "win32":
monkeypatch.setenv("USERPROFILE", home)
else:
monkeypatch.setenv("HOME", home)
yield home
class Dotfiles:
"""Create and manage a dotfiles directory for a test."""
def __init__(self, root):
self.root = root
self.config = None
self.config_filename = None
self.directory = os.path.join(root, "dotfiles")
os.mkdir(self.directory)
def makedirs(self, path):
os.makedirs(os.path.abspath(os.path.join(self.directory, path)))
def write(self, path, content=""):
path = os.path.abspath(os.path.join(self.directory, path))
if not os.path.exists(os.path.dirname(path)):
os.makedirs(os.path.dirname(path))
with open(path, "w") as file:
file.write(content)
def write_config(self, config, serializer="yaml", path=None):
"""Write a dotbot config and return the filename."""
assert serializer in {"json", "yaml"}, "Only json and yaml are supported"
if serializer == "yaml":
serialize = yaml.dump
else: # serializer == "json"
serialize = json.dumps
if path:
msg = "The config file path must be an absolute path"
assert path == os.path.abspath(path), msg
msg = "The config file path must be rooted in {0}"
msg = msg.format(root)
assert path[: len(str(root))] == str(root), msg
self.config_filename = path
else:
self.config_filename = os.path.join(self.directory, "install.conf.yaml")
self.config = config
with open(self.config_filename, "w") as file:
file.write(serialize(config))
return self.config_filename
@pytest.fixture
def dotfiles(root):
"""Create a dotfiles directory."""
yield Dotfiles(root)
@pytest.fixture
def run_dotbot(dotfiles):
"""Run dotbot.
When calling `runner()`, only CLI arguments need to be specified.
If the keyword-only argument *custom* is True
then the CLI arguments will not be modified,
and the caller will be responsible for all CLI arguments.
"""
def runner(*argv, **kwargs):
argv = ["dotbot"] + list(argv)
if kwargs.get("custom", False) is not True:
argv.extend(["-c", dotfiles.config_filename])
with mock.patch("sys.argv", argv):
dotbot.cli.main()
yield runner

View file

@ -0,0 +1,27 @@
"""Test that a plugin can be loaded by directory.
This file is copied to a location with the name "directory.py",
and is then loaded from within the `test_cli.py` code.
"""
import os.path
import dotbot
class Directory(dotbot.Plugin):
def can_handle(self, directive):
return directive == "plugin_directory"
def handle(self, directive, data):
self._log.debug("Attempting to get options from Context")
options = self._context.options()
if len(options.plugin_dirs) != 1:
self._log.debug(
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
)
return False
with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file:
file.write("directory plugin loading works")
return True

View file

@ -0,0 +1,32 @@
"""Test that a plugin can be loaded by filename.
This file is copied to a location with the name "file.py",
and is then loaded from within the `test_cli.py` code.
"""
import os.path
import dotbot
class File(dotbot.Plugin):
def can_handle(self, directive):
return directive == "plugin_file"
def handle(self, directive, data):
self._log.debug("Attempting to get options from Context")
options = self._context.options()
if len(options.plugins) != 1:
self._log.debug(
"Context.options.plugins length is %i, expected 1" % len(options.plugins)
)
return False
if not options.plugins[0].endswith("file.py"):
self._log.debug(
"Context.options.plugins[0] is %s, expected end with file.py" % options.plugins[0]
)
return False
with open(os.path.abspath(os.path.expanduser("~/flag")), "w") as file:
file.write("file plugin loading works")
return True

45
tests/test_bin_dotbot.py Normal file
View file

@ -0,0 +1,45 @@
import os
import shutil
import subprocess
import pytest
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms",
)
@pytest.mark.parametrize("python_name", (None, "python", "python3"))
def test_find_python_executable(python_name, home, dotfiles):
"""Verify that the sh/Python hybrid dotbot executable can find Python."""
dotfiles.write_config([])
dotbot_executable = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "dotbot"
)
# Create a link to sh.
tmp_bin = os.path.join(home, "tmp_bin")
os.makedirs(tmp_bin)
sh_path = shutil.which("sh")
os.symlink(sh_path, os.path.join(tmp_bin, "sh"))
if python_name:
with open(os.path.join(tmp_bin, python_name), "w") as file:
file.write("#!" + tmp_bin + "/sh\n")
file.write("exit 0\n")
os.chmod(os.path.join(tmp_bin, python_name), 0o777)
env = dict(os.environ)
env["PATH"] = tmp_bin
if python_name:
subprocess.check_call(
[dotbot_executable, "-c", dotfiles.config_filename],
env=env,
)
else:
with pytest.raises(subprocess.CalledProcessError):
subprocess.check_call(
[dotbot_executable, "-c", dotfiles.config_filename],
env=env,
)

136
tests/test_clean.py Normal file
View file

@ -0,0 +1,136 @@
import os
import sys
import pytest
def test_clean_default(root, home, dotfiles, run_dotbot):
"""Verify clean uses default unless overridden."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config(
[
{
"clean": {
"~/nonexistent": {"force": True},
"~/": None,
},
}
]
)
run_dotbot()
assert not os.path.isdir(os.path.join(home, "nonexistent"))
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_environment_variable_expansion(home, dotfiles, run_dotbot):
"""Verify clean expands environment variables."""
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
variable = "$HOME"
if sys.platform[:5] == "win32":
variable = "$USERPROFILE"
dotfiles.write_config([{"clean": [variable]}])
run_dotbot()
assert not os.path.islink(os.path.join(home, ".f"))
def test_clean_missing(home, dotfiles, run_dotbot):
"""Verify clean deletes links to missing files."""
dotfiles.write("f")
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
os.symlink(os.path.join(dotfiles.directory, "g"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": ["~"]}])
run_dotbot()
assert os.path.islink(os.path.join(home, ".f"))
assert not os.path.islink(os.path.join(home, ".g"))
def test_clean_nonexistent(home, dotfiles, run_dotbot):
"""Verify clean ignores nonexistent directories."""
dotfiles.write_config([{"clean": ["~", "~/fake"]}])
run_dotbot() # Nonexistent directories should not raise exceptions.
assert not os.path.isdir(os.path.join(home, "fake"))
def test_clean_outside_force(root, home, dotfiles, run_dotbot):
"""Verify clean forced to remove files linking outside dotfiles directory."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": {"~/": {"force": True}}}])
run_dotbot()
assert not os.path.islink(os.path.join(home, ".g"))
def test_clean_outside(root, home, dotfiles, run_dotbot):
"""Verify clean ignores files linking outside dotfiles directory."""
os.symlink(os.path.join(dotfiles.directory, "f"), os.path.join(home, ".f"))
os.symlink(os.path.join(home, "g"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": ["~"]}])
run_dotbot()
assert not os.path.islink(os.path.join(home, ".f"))
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_recursive_1(root, home, dotfiles, run_dotbot):
"""Verify clean respects when the recursive directive is off (default)."""
os.makedirs(os.path.join(home, "a", "b"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e"))
dotfiles.write_config([{"clean": {"~": {"force": True}}}])
run_dotbot()
assert not os.path.islink(os.path.join(home, "c"))
assert os.path.islink(os.path.join(home, "a", "d"))
assert os.path.islink(os.path.join(home, "a", "b", "e"))
def test_clean_recursive_2(root, home, dotfiles, run_dotbot):
"""Verify clean respects when the recursive directive is on."""
os.makedirs(os.path.join(home, "a", "b"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "c"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "d"))
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, "a", "b", "e"))
dotfiles.write_config([{"clean": {"~": {"force": True, "recursive": True}}}])
run_dotbot()
assert not os.path.islink(os.path.join(home, "c"))
assert not os.path.islink(os.path.join(home, "a", "d"))
assert not os.path.islink(os.path.join(home, "a", "b", "e"))
def test_clean_defaults_1(root, home, dotfiles, run_dotbot):
"""Verify that clean doesn't erase non-dotfiles links by default."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config([{"clean": ["~"]}])
run_dotbot()
assert os.path.islink(os.path.join(home, ".g"))
def test_clean_defaults_2(root, home, dotfiles, run_dotbot):
"""Verify that explicit clean defaults override the implicit default."""
os.symlink(os.path.join(root, "nowhere"), os.path.join(home, ".g"))
dotfiles.write_config(
[
{"defaults": {"clean": {"force": True}}},
{"clean": ["~"]},
]
)
run_dotbot()
assert not os.path.islink(os.path.join(home, ".g"))

172
tests/test_cli.py Normal file
View file

@ -0,0 +1,172 @@
import os
import shutil
import pytest
def test_except_create(capfd, home, dotfiles, run_dotbot):
"""Verify that `--except` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{
"shell": [
{"command": "echo success", "stdout": True},
]
},
]
)
run_dotbot("--except", "create")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
def test_except_shell(capfd, home, dotfiles, run_dotbot):
"""Verify that `--except` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{
"shell": [
{"command": "echo failure", "stdout": True},
]
},
]
)
run_dotbot("--except", "shell")
assert os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("failure") for line in stdout)
def test_except_multiples(capfd, home, dotfiles, run_dotbot):
"""Verify that `--except` works with multiple exceptions."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{
"shell": [
{"command": "echo failure", "stdout": True},
]
},
]
)
run_dotbot("--except", "create", "shell")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("failure") for line in stdout)
def test_exit_on_failure(capfd, home, dotfiles, run_dotbot):
"""Verify that processing can halt immediately on failures."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{"shell": ["this_is_not_a_command"]},
{"create": ["~/b"]},
]
)
with pytest.raises(SystemExit):
run_dotbot("-x")
assert os.path.isdir(os.path.join(home, "a"))
assert not os.path.isdir(os.path.join(home, "b"))
def test_only(capfd, home, dotfiles, run_dotbot):
"""Verify that `--only` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{"shell": [{"command": "echo success", "stdout": True}]},
]
)
run_dotbot("--only", "shell")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
def test_only_with_defaults(capfd, home, dotfiles, run_dotbot):
"""Verify that `--only` does not suppress defaults."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"create": ["~/a"]},
{"shell": [{"command": "echo success"}]},
]
)
run_dotbot("--only", "shell")
assert not os.path.exists(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
def test_only_with_multiples(capfd, home, dotfiles, run_dotbot):
"""Verify that `--only` works as intended."""
dotfiles.write_config(
[
{"create": ["~/a"]},
{"shell": [{"command": "echo success", "stdout": True}]},
{"link": ["~/.f"]},
]
)
run_dotbot("--only", "create", "shell")
assert os.path.isdir(os.path.join(home, "a"))
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("success") for line in stdout)
assert not os.path.exists(os.path.join(home, ".f"))
def test_plugin_loading_file(home, dotfiles, run_dotbot):
"""Verify that plugins can be loaded by file."""
plugin_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_file.py")
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "file.py"))
dotfiles.write_config([{"plugin_file": "~"}])
run_dotbot("--plugin", os.path.join(dotfiles.directory, "file.py"))
with open(os.path.join(home, "flag"), "r") as file:
assert file.read() == "file plugin loading works"
def test_plugin_loading_directory(home, dotfiles, run_dotbot):
"""Verify that plugins can be loaded from a directory."""
dotfiles.makedirs("plugins")
plugin_file = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "dotbot_plugin_directory.py"
)
shutil.copy(plugin_file, os.path.join(dotfiles.directory, "plugins", "directory.py"))
dotfiles.write_config([{"plugin_directory": "~"}])
run_dotbot("--plugin-dir", os.path.join(dotfiles.directory, "plugins"))
with open(os.path.join(home, "flag"), "r") as file:
assert file.read() == "directory plugin loading works"
def test_disable_builtin_plugins(home, dotfiles, run_dotbot):
"""Verify that builtin plugins can be disabled."""
dotfiles.write("f", "apple")
dotfiles.write_config([{"link": {"~/.f": "f"}}])
# The link directive will be unhandled so dotbot will raise SystemExit.
with pytest.raises(SystemExit):
run_dotbot("--disable-built-in-plugins")
assert not os.path.exists(os.path.join(home, ".f"))

36
tests/test_config.py Normal file
View file

@ -0,0 +1,36 @@
import json
import os
def test_config_blank(dotfiles, run_dotbot):
"""Verify blank configs work."""
dotfiles.write_config([])
run_dotbot()
def test_config_empty(dotfiles, run_dotbot):
"""Verify empty configs work."""
dotfiles.write("config.yaml", "")
run_dotbot("-c", os.path.join(dotfiles.directory, "config.yaml"), custom=True)
def test_json(home, dotfiles, run_dotbot):
"""Verify JSON configs work."""
document = json.dumps([{"create": ["~/d"]}])
dotfiles.write("config.json", document)
run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True)
assert os.path.isdir(os.path.join(home, "d"))
def test_json_tabs(home, dotfiles, run_dotbot):
"""Verify JSON configs with tabs work."""
document = """[\n\t{\n\t\t"create": ["~/d"]\n\t}\n]"""
dotfiles.write("config.json", document)
run_dotbot("-c", os.path.join(dotfiles.directory, "config.json"), custom=True)
assert os.path.isdir(os.path.join(home, "d"))

55
tests/test_create.py Normal file
View file

@ -0,0 +1,55 @@
import os
import stat
import pytest
@pytest.mark.parametrize("directory", ("~/a", "~/b/c"))
def test_directory_creation(home, directory, dotfiles, run_dotbot):
"""Test creating directories, including nested directories."""
dotfiles.write_config([{"create": [directory]}])
run_dotbot()
expanded_directory = os.path.abspath(os.path.expanduser(directory))
assert os.path.isdir(expanded_directory)
assert os.stat(expanded_directory).st_mode & 0o777 == 0o777
def test_default_mode(home, dotfiles, run_dotbot):
"""Test creating a directory with an explicit default mode.
Note: `os.chmod()` on Windows only supports changing write permissions.
Therefore, this test is restricted to testing read-only access.
"""
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
config = [{"defaults": {"create": {"mode": read_only}}}, {"create": ["~/a"]}]
dotfiles.write_config(config)
run_dotbot()
directory = os.path.abspath(os.path.expanduser("~/a"))
assert os.stat(directory).st_mode & stat.S_IWUSR == 0
assert os.stat(directory).st_mode & stat.S_IWGRP == 0
assert os.stat(directory).st_mode & stat.S_IWOTH == 0
def test_default_mode_override(home, dotfiles, run_dotbot):
"""Test creating a directory that overrides an explicit default mode.
Note: `os.chmod()` on Windows only supports changing write permissions.
Therefore, this test is restricted to testing read-only access.
"""
read_only = 0o777 - stat.S_IWUSR - stat.S_IWGRP - stat.S_IWOTH
config = [
{"defaults": {"create": {"mode": read_only}}},
{"create": {"~/a": {"mode": 0o777}}},
]
dotfiles.write_config(config)
run_dotbot()
directory = os.path.abspath(os.path.expanduser("~/a"))
assert os.stat(directory).st_mode & stat.S_IWUSR == stat.S_IWUSR
assert os.stat(directory).st_mode & stat.S_IWGRP == stat.S_IWGRP
assert os.stat(directory).st_mode & stat.S_IWOTH == stat.S_IWOTH

967
tests/test_link.py Normal file
View file

@ -0,0 +1,967 @@
import os
import sys
import pytest
def test_link_canonicalization(home, dotfiles, run_dotbot):
"""Verify links to symlinked destinations are canonical.
"Canonical", here, means that dotbot does not create symlinks
that point to intermediary symlinks.
"""
dotfiles.write("f", "apple")
dotfiles.write_config([{"link": {"~/.f": {"path": "f"}}}])
# Point to the config file in a symlinked dotfiles directory.
dotfiles_symlink = os.path.join(home, "dotfiles-symlink")
os.symlink(dotfiles.directory, dotfiles_symlink)
config_file = os.path.join(dotfiles_symlink, os.path.basename(dotfiles.config_filename))
run_dotbot("-c", config_file, custom=True)
expected = os.path.join(dotfiles.directory, "f")
actual = os.readlink(os.path.abspath(os.path.expanduser("~/.f")))
if sys.platform[:5] == "win32" and actual.startswith("\\\\?\\"):
actual = actual[4:]
assert expected == actual
@pytest.mark.parametrize("dst", ("~/.f", "~/f"))
@pytest.mark.parametrize("include_force", (True, False))
def test_link_default_source(root, home, dst, include_force, dotfiles, run_dotbot):
"""Verify that default sources are calculated correctly.
This test includes verifying files with and without leading periods,
as well as verifying handling of None dict values.
"""
dotfiles.write("f", "apple")
config = [
{
"link": {
dst: {"force": False} if include_force else None,
}
}
]
dotfiles.write_config(config)
run_dotbot()
with open(os.path.abspath(os.path.expanduser(dst)), "r") as file:
assert file.read() == "apple"
def test_link_environment_user_expansion_target(home, dotfiles, run_dotbot):
"""Verify link expands user in target."""
src = "~/f"
target = "~/g"
with open(os.path.abspath(os.path.expanduser(src)), "w") as file:
file.write("apple")
dotfiles.write_config([{"link": {target: src}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
assert file.read() == "apple"
def test_link_environment_variable_expansion_source(monkeypatch, root, home, dotfiles, run_dotbot):
"""Verify link expands environment variables in source."""
monkeypatch.setenv("APPLE", "h")
target = "~/.i"
src = "$APPLE"
dotfiles.write("h", "grape")
dotfiles.write_config([{"link": {target: src}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
assert file.read() == "grape"
def test_link_environment_variable_expansion_source_extended(
monkeypatch, root, home, dotfiles, run_dotbot
):
"""Verify link expands environment variables in extended config syntax."""
monkeypatch.setenv("APPLE", "h")
target = "~/.i"
src = "$APPLE"
dotfiles.write("h", "grape")
dotfiles.write_config([{"link": {target: {"path": src, "relink": True}}}])
run_dotbot()
with open(os.path.abspath(os.path.expanduser(target)), "r") as file:
assert file.read() == "grape"
def test_link_environment_variable_expansion_target(monkeypatch, root, home, dotfiles, run_dotbot):
"""Verify link expands environment variables in target.
If the variable doesn't exist, the "variable" must not be replaced.
"""
monkeypatch.setenv("ORANGE", ".config")
monkeypatch.setenv("BANANA", "g")
monkeypatch.delenv("PEAR", raising=False)
dotfiles.write("f", "apple")
dotfiles.write("h", "grape")
config = [
{
"link": {
"~/${ORANGE}/$BANANA": {
"path": "f",
"create": True,
},
"~/$PEAR": "h",
}
}
]
dotfiles.write_config(config)
run_dotbot()
with open(os.path.join(home, ".config", "g"), "r") as file:
assert file.read() == "apple"
with open(os.path.join(home, "$PEAR"), "r") as file:
assert file.read() == "grape"
def test_link_environment_variable_unset(monkeypatch, root, home, dotfiles, run_dotbot):
"""Verify link leaves unset environment variables."""
monkeypatch.delenv("ORANGE", raising=False)
dotfiles.write("$ORANGE", "apple")
dotfiles.write_config([{"link": {"~/f": "$ORANGE"}}])
run_dotbot()
with open(os.path.join(home, "f"), "r") as file:
assert file.read() == "apple"
def test_link_force_leaves_when_nonexistent(root, home, dotfiles, run_dotbot):
"""Verify force doesn't erase sources when targets are nonexistent."""
os.mkdir(os.path.join(home, "dir"))
open(os.path.join(home, "file"), "a").close()
config = [
{
"link": {
"~/dir": {"path": "dir", "force": True},
"~/file": {"path": "file", "force": True},
}
}
]
dotfiles.write_config(config)
with pytest.raises(SystemExit):
run_dotbot()
assert os.path.isdir(os.path.join(home, "dir"))
assert os.path.isfile(os.path.join(home, "file"))
def test_link_force_overwrite_symlink(home, dotfiles, run_dotbot):
"""Verify force overwrites a symlinked directory."""
os.mkdir(os.path.join(home, "dir"))
dotfiles.write("dir/f")
os.symlink(home, os.path.join(home, ".dir"))
config = [{"link": {"~/.dir": {"path": "dir", "force": True}}}]
dotfiles.write_config(config)
run_dotbot()
assert os.path.isfile(os.path.join(home, ".dir", "f"))
def test_link_glob_1(home, dotfiles, run_dotbot):
"""Verify globbing works."""
dotfiles.write("bin/a", "apple")
dotfiles.write("bin/b", "banana")
dotfiles.write("bin/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/bin": "bin/*"}},
]
)
run_dotbot()
with open(os.path.join(home, "bin", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, "bin", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, "bin", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_2(home, dotfiles, run_dotbot):
"""Verify globbing works with a trailing slash in the source."""
dotfiles.write("bin/a", "apple")
dotfiles.write("bin/b", "banana")
dotfiles.write("bin/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/bin/": "bin/*"}},
]
)
run_dotbot()
with open(os.path.join(home, "bin", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, "bin", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, "bin", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_3(home, dotfiles, run_dotbot):
"""Verify globbing works with hidden ("period-prefixed") files."""
dotfiles.write("bin/.a", "dot-apple")
dotfiles.write("bin/.b", "dot-banana")
dotfiles.write("bin/.c", "dot-cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/bin/": "bin/.*"}},
]
)
run_dotbot()
with open(os.path.join(home, "bin", ".a")) as file:
assert file.read() == "dot-apple"
with open(os.path.join(home, "bin", ".b")) as file:
assert file.read() == "dot-banana"
with open(os.path.join(home, "bin", ".c")) as file:
assert file.read() == "dot-cherry"
def test_link_glob_4(home, dotfiles, run_dotbot):
"""Verify globbing works at the root of the home and dotfiles directories."""
dotfiles.write(".a", "dot-apple")
dotfiles.write(".b", "dot-banana")
dotfiles.write(".c", "dot-cherry")
dotfiles.write_config(
[
{
"link": {
"~": {
"path": ".*",
"glob": True,
},
},
}
]
)
run_dotbot()
with open(os.path.join(home, ".a")) as file:
assert file.read() == "dot-apple"
with open(os.path.join(home, ".b")) as file:
assert file.read() == "dot-banana"
with open(os.path.join(home, ".c")) as file:
assert file.read() == "dot-cherry"
@pytest.mark.parametrize("path", ("foo", "foo/"))
def test_link_glob_ignore_no_glob_chars(path, home, dotfiles, run_dotbot):
"""Verify ambiguous link globbing fails."""
dotfiles.makedirs("foo")
dotfiles.write_config(
[
{
"link": {
"~/foo/": {
"path": path,
"glob": True,
}
}
}
]
)
run_dotbot()
assert os.path.islink(os.path.join(home, "foo"))
assert os.path.exists(os.path.join(home, "foo"))
def test_link_glob_exclude_1(home, dotfiles, run_dotbot):
"""Verify link globbing with an explicit exclusion."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*",
"exclude": ["config/baz"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert os.path.islink(os.path.join(home, ".config", "foo"))
assert os.path.islink(os.path.join(home, ".config", "bar"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_exclude_2(home, dotfiles, run_dotbot):
"""Verify deep link globbing with a globbed exclusion."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write("config/baz/buzz/e", "egg")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*/*",
"exclude": ["config/baz/*"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_exclude_3(home, dotfiles, run_dotbot):
"""Verify deep link globbing with an explicit exclusion."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write("config/baz/buzz/e", "egg")
dotfiles.write("config/baz/bizz/g", "grape")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*/*",
"exclude": ["config/baz/buzz"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz", "buzz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert not os.path.islink(os.path.join(home, ".config", "baz"))
assert os.path.islink(os.path.join(home, ".config", "baz", "bizz"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
with open(os.path.join(home, ".config", "baz", "d")) as file:
assert file.read() == "donut"
with open(os.path.join(home, ".config", "baz", "bizz", "g")) as file:
assert file.read() == "grape"
def test_link_glob_exclude_4(home, dotfiles, run_dotbot):
"""Verify deep link globbing with multiple globbed exclusions."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write("config/baz/d", "donut")
dotfiles.write("config/baz/buzz/e", "egg")
dotfiles.write("config/baz/bizz/g", "grape")
dotfiles.write("config/fiz/f", "fig")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"glob": True,
"create": True,
},
},
},
{
"link": {
"~/.config/": {
"path": "config/*/*",
"exclude": ["config/baz/*", "config/fiz/*"],
},
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".config", "baz"))
assert not os.path.exists(os.path.join(home, ".config", "fiz"))
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_multi_star(home, dotfiles, run_dotbot):
"""Verify link globbing with deep-nested stars."""
dotfiles.write("config/foo/a", "apple")
dotfiles.write("config/bar/b", "banana")
dotfiles.write("config/bar/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/.config/": "config/*/*"}},
]
)
run_dotbot()
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "bar", "b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".config", "bar", "c")) as file:
assert file.read() == "cherry"
@pytest.mark.parametrize(
"pattern, expect_file",
(
("conf/*", lambda fruit: fruit),
("conf/.*", lambda fruit: "." + fruit),
("conf/[bc]*", lambda fruit: fruit if fruit[0] in "bc" else None),
("conf/*e", lambda fruit: fruit if fruit[-1] == "e" else None),
("conf/??r*", lambda fruit: fruit if fruit[2] == "r" else None),
),
)
def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot):
"""Verify link glob pattern matching."""
fruits = ["apple", "apricot", "banana", "cherry", "currant", "cantalope"]
[dotfiles.write("conf/" + fruit, fruit) for fruit in fruits]
[dotfiles.write("conf/." + fruit, "dot-" + fruit) for fruit in fruits]
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/globtest": pattern}},
]
)
run_dotbot()
for fruit in fruits:
if expect_file(fruit) is None:
assert not os.path.exists(os.path.join(home, "globtest", fruit))
assert not os.path.exists(os.path.join(home, "globtest", "." + fruit))
elif "." in expect_file(fruit):
assert not os.path.islink(os.path.join(home, "globtest", fruit))
assert os.path.islink(os.path.join(home, "globtest", "." + fruit))
else: # "." not in expect_file(fruit)
assert os.path.islink(os.path.join(home, "globtest", fruit))
assert not os.path.islink(os.path.join(home, "globtest", "." + fruit))
def test_link_glob_recursive(home, dotfiles, run_dotbot):
"""Verify recursive link globbing and exclusions."""
dotfiles.write("config/foo/bar/a", "apple")
dotfiles.write("config/foo/bar/b", "banana")
dotfiles.write("config/foo/bar/c", "cherry")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/.config/": {"path": "config/**", "exclude": ["config/**/b"]}}},
]
)
run_dotbot()
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert not os.path.islink(os.path.join(home, ".config", "foo", "bar"))
assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "a"))
assert not os.path.exists(os.path.join(home, ".config", "foo", "bar", "b"))
assert os.path.islink(os.path.join(home, ".config", "foo", "bar", "c"))
with open(os.path.join(home, ".config", "foo", "bar", "a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".config", "foo", "bar", "c")) as file:
assert file.read() == "cherry"
def test_link_glob_no_match(home, dotfiles, run_dotbot):
"""Verify that a glob with no match doesn't raise an error."""
dotfiles.makedirs("foo")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/.config/foo": "foo/*"}},
]
)
run_dotbot()
def test_link_glob_single_match(home, dotfiles, run_dotbot):
"""Verify linking works even when glob matches exactly one file."""
# regression test for https://github.com/anishathalye/dotbot/issues/282
dotfiles.write("foo/a", "apple")
dotfiles.write_config(
[
{"defaults": {"link": {"glob": True, "create": True}}},
{"link": {"~/.config/foo": "foo/*"}},
]
)
run_dotbot()
assert not os.path.islink(os.path.join(home, ".config"))
assert not os.path.islink(os.path.join(home, ".config", "foo"))
assert os.path.islink(os.path.join(home, ".config", "foo", "a"))
with open(os.path.join(home, ".config", "foo", "a")) as file:
assert file.read() == "apple"
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
reason="These if commands won't run on Windows",
)
def test_link_if(home, dotfiles, run_dotbot):
"""Verify 'if' directives are checked when linking."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"link": {
"~/.f": {"path": "f", "if": "true"},
"~/.g": {"path": "f", "if": "false"},
"~/.h": {"path": "f", "if": "[ -d ~/d ]"},
"~/.i": {"path": "f", "if": "badcommand"},
},
}
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".g"))
assert not os.path.exists(os.path.join(home, ".i"))
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".h")) as file:
assert file.read() == "apple"
@pytest.mark.skipif(
"sys.platform[:5] == 'win32'",
reason="These if commands won't run on Windows.",
)
def test_link_if_defaults(home, dotfiles, run_dotbot):
"""Verify 'if' directive defaults are checked when linking."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"if": "false",
},
},
},
{
"link": {
"~/.j": {"path": "f", "if": "true"},
"~/.k": {"path": "f"}, # default is false
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".k"))
with open(os.path.join(home, ".j")) as file:
assert file.read() == "apple"
@pytest.mark.skipif(
"sys.platform[:5] != 'win32'",
reason="These if commands only run on Windows.",
)
def test_link_if_windows(home, dotfiles, run_dotbot):
"""Verify 'if' directives are checked when linking (Windows only)."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"link": {
"~/.f": {"path": "f", "if": 'cmd /c "exit 0"'},
"~/.g": {"path": "f", "if": 'cmd /c "exit 1"'},
"~/.h": {"path": "f", "if": 'cmd /c "dir %USERPROFILE%\\d'},
"~/.i": {"path": "f", "if": 'cmd /c "badcommand"'},
},
}
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".g"))
assert not os.path.exists(os.path.join(home, ".i"))
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".h")) as file:
assert file.read() == "apple"
@pytest.mark.skipif(
"sys.platform[:5] != 'win32'",
reason="These if commands only run on Windows",
)
def test_link_if_defaults_windows(home, dotfiles, run_dotbot):
"""Verify 'if' directive defaults are checked when linking (Windows only)."""
os.mkdir(os.path.join(home, "d"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"defaults": {
"link": {
"if": 'cmd /c "exit 1"',
},
},
},
{
"link": {
"~/.j": {"path": "f", "if": 'cmd /c "exit 0"'},
"~/.k": {"path": "f"}, # default is false
},
},
]
)
run_dotbot()
assert not os.path.exists(os.path.join(home, ".k"))
with open(os.path.join(home, ".j")) as file:
assert file.read() == "apple"
@pytest.mark.parametrize("ignore_missing", (True, False))
def test_link_ignore_missing(ignore_missing, home, dotfiles, run_dotbot):
"""Verify link 'ignore_missing' is respected when the target is missing."""
dotfiles.write_config(
[
{
"link": {
"~/missing_link": {
"path": "missing",
"ignore-missing": ignore_missing,
},
},
}
]
)
if ignore_missing:
run_dotbot()
assert os.path.islink(os.path.join(home, "missing_link"))
else:
with pytest.raises(SystemExit):
run_dotbot()
def test_link_leaves_file(home, dotfiles, run_dotbot):
"""Verify relink does not overwrite file."""
dotfiles.write("f", "apple")
with open(os.path.join(home, ".f"), "w") as file:
file.write("grape")
dotfiles.write_config([{"link": {"~/.f": "f"}}])
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "grape"
@pytest.mark.parametrize("key", ("canonicalize-path", "canonicalize"))
def test_link_no_canonicalize(key, home, dotfiles, run_dotbot):
"""Verify link canonicalization can be disabled."""
dotfiles.write("f", "apple")
dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}])
os.symlink(
dotfiles.directory,
os.path.join(home, "dotfiles-symlink"),
target_is_directory=True,
)
run_dotbot(
"-c",
os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)),
custom=True,
)
assert "dotfiles-symlink" in os.readlink(os.path.join(home, ".f"))
def test_link_prefix(home, dotfiles, run_dotbot):
"""Verify link prefixes are prepended."""
dotfiles.write("conf/a", "apple")
dotfiles.write("conf/b", "banana")
dotfiles.write("conf/c", "cherry")
dotfiles.write_config(
[
{
"link": {
"~/": {
"glob": True,
"path": "conf/*",
"prefix": ".",
},
},
}
]
)
run_dotbot()
with open(os.path.join(home, ".a")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".b")) as file:
assert file.read() == "banana"
with open(os.path.join(home, ".c")) as file:
assert file.read() == "cherry"
def test_link_relative(home, dotfiles, run_dotbot):
"""Test relative linking works."""
dotfiles.write("f", "apple")
dotfiles.write("d/e", "grape")
dotfiles.write_config(
[
{
"link": {
"~/.f": {
"path": "f",
},
"~/.frel": {
"path": "f",
"relative": True,
},
"~/nested/.frel": {
"path": "f",
"relative": True,
"create": True,
},
"~/.d": {
"path": "d",
"relative": True,
},
},
}
]
)
run_dotbot()
f = os.readlink(os.path.join(home, ".f"))
if sys.platform[:5] == "win32" and f.startswith("\\\\?\\"):
f = f[4:]
assert f == os.path.join(dotfiles.directory, "f")
frel = os.readlink(os.path.join(home, ".frel"))
if sys.platform[:5] == "win32" and frel.startswith("\\\\?\\"):
frel = frel[4:]
assert frel == os.path.normpath("../../dotfiles/f")
nested_frel = os.readlink(os.path.join(home, "nested", ".frel"))
if sys.platform[:5] == "win32" and nested_frel.startswith("\\\\?\\"):
nested_frel = nested_frel[4:]
assert nested_frel == os.path.normpath("../../../dotfiles/f")
d = os.readlink(os.path.join(home, ".d"))
if sys.platform[:5] == "win32" and d.startswith("\\\\?\\"):
d = d[4:]
assert d == os.path.normpath("../../dotfiles/d")
with open(os.path.join(home, ".f")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".frel")) as file:
assert file.read() == "apple"
with open(os.path.join(home, "nested", ".frel")) as file:
assert file.read() == "apple"
with open(os.path.join(home, ".d", "e")) as file:
assert file.read() == "grape"
def test_link_relink_leaves_file(home, dotfiles, run_dotbot):
"""Verify relink does not overwrite file."""
dotfiles.write("f", "apple")
with open(os.path.join(home, ".f"), "w") as file:
file.write("grape")
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "grape"
def test_link_relink_overwrite_symlink(home, dotfiles, run_dotbot):
"""Verify relink overwrites symlinks."""
dotfiles.write("f", "apple")
with open(os.path.join(home, "f"), "w") as file:
file.write("grape")
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
dotfiles.write_config([{"link": {"~/.f": {"path": "f", "relink": True}}}])
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "apple"
def test_link_relink_relative_leaves_file(home, dotfiles, run_dotbot):
"""Verify relink relative does not incorrectly relink file."""
dotfiles.write("f", "apple")
with open(os.path.join(home, ".f"), "w") as file:
file.write("grape")
config = [
{
"link": {
"~/.folder/f": {
"path": "f",
"create": True,
"relative": True,
},
},
}
]
dotfiles.write_config(config)
run_dotbot()
mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
config[0]["link"]["~/.folder/f"]["relink"] = True
dotfiles.write_config(config)
run_dotbot()
new_mtime = os.stat(os.path.join(home, ".folder", "f")).st_mtime
assert mtime == new_mtime
def test_link_defaults_1(home, dotfiles, run_dotbot):
"""Verify that link doesn't overwrite non-dotfiles links by default."""
with open(os.path.join(home, "f"), "w") as file:
file.write("grape")
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{
"link": {"~/.f": "f"},
}
]
)
with pytest.raises(SystemExit):
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "grape"
def test_link_defaults_2(home, dotfiles, run_dotbot):
"""Verify that explicit link defaults override the implicit default."""
with open(os.path.join(home, "f"), "w") as file:
file.write("grape")
os.symlink(os.path.join(home, "f"), os.path.join(home, ".f"))
dotfiles.write("f", "apple")
dotfiles.write_config(
[
{"defaults": {"link": {"relink": True}}},
{"link": {"~/.f": "f"}},
]
)
run_dotbot()
with open(os.path.join(home, ".f"), "r") as file:
assert file.read() == "apple"

25
tests/test_noop.py Normal file
View file

@ -0,0 +1,25 @@
import os
import pytest
def test_success(root):
path = os.path.join(root, "abc.txt")
with open(path, "wt") as f:
f.write("hello")
with open(path, "rt") as f:
assert f.read() == "hello"
def test_failure():
with pytest.raises(AssertionError):
open("abc.txt", "w")
with pytest.raises(AssertionError):
open(file="abc.txt", mode="w")
with pytest.raises(AssertionError):
os.mkdir("a")
with pytest.raises(AssertionError):
os.mkdir(path="a")

261
tests/test_shell.py Normal file
View file

@ -0,0 +1,261 @@
def test_shell_allow_stdout(capfd, dotfiles, run_dotbot):
"""Verify shell command STDOUT works."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo apple",
"stdout": True,
}
],
}
]
)
run_dotbot()
output = capfd.readouterr()
assert any([line.startswith("apple") for line in output.out.splitlines()]), output
def test_shell_cli_verbosity_overrides_1(capfd, dotfiles, run_dotbot):
"""Verify that '-vv' overrides the implicit default stdout=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple"}]}])
run_dotbot("-vv")
lines = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in lines)
def test_shell_cli_verbosity_overrides_2(capfd, dotfiles, run_dotbot):
"""Verify that '-vv' overrides an explicit stdout=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple", "stdout": False}]}])
run_dotbot("-vv")
lines = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in lines)
def test_shell_cli_verbosity_overrides_3(capfd, dotfiles, run_dotbot):
"""Verify that '-vv' overrides an explicit defaults:shell:stdout=False."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": False}}},
{"shell": [{"command": "echo apple"}]},
]
)
run_dotbot("-vv")
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in stdout)
def test_shell_cli_verbosity_stderr(capfd, dotfiles, run_dotbot):
"""Verify that commands can output to STDERR."""
dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}])
run_dotbot("-vv")
stderr = capfd.readouterr().err.splitlines()
assert any(line.startswith("apple") for line in stderr)
def test_shell_cli_verbosity_stderr_with_explicit_stdout_off(capfd, dotfiles, run_dotbot):
"""Verify that commands can output to STDERR with STDOUT explicitly off."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo apple >&2",
"stdout": False,
}
],
}
]
)
run_dotbot("-vv")
stderr = capfd.readouterr().err.splitlines()
assert any(line.startswith("apple") for line in stderr)
def test_shell_cli_verbosity_stderr_with_defaults_stdout_off(capfd, dotfiles, run_dotbot):
"""Verify that commands can output to STDERR with defaults:shell:stdout=False."""
dotfiles.write_config(
[
{
"defaults": {
"shell": {
"stdout": False,
},
},
},
{
"shell": [
{"command": "echo apple >&2"},
],
},
]
)
run_dotbot("-vv")
stderr = capfd.readouterr().err.splitlines()
assert any(line.startswith("apple") for line in stderr)
def test_shell_single_v_verbosity_stdout(capfd, dotfiles, run_dotbot):
"""Verify that a single '-v' verbosity doesn't override stdout=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple"}]}])
run_dotbot("-v")
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("apple") for line in stdout)
def test_shell_single_v_verbosity_stderr(capfd, dotfiles, run_dotbot):
"""Verify that a single '-v' verbosity doesn't override stderr=False."""
dotfiles.write_config([{"shell": [{"command": "echo apple >&2"}]}])
run_dotbot("-v")
stderr = capfd.readouterr().err.splitlines()
assert not any(line.startswith("apple") for line in stderr)
def test_shell_compact_stdout_1(capfd, dotfiles, run_dotbot):
"""Verify that shell command stdout works in compact form."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"shell": ["echo apple"]},
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in stdout)
def test_shell_compact_stdout_2(capfd, dotfiles, run_dotbot):
"""Verify that shell command stdout works in compact form."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"shell": [["echo apple", "echoing message"]]},
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert any(line.startswith("apple") for line in stdout)
assert any(line.startswith("echoing message") for line in stdout)
def test_shell_stdout_disabled_by_default(capfd, dotfiles, run_dotbot):
"""Verify that the shell command disables stdout by default."""
dotfiles.write_config(
[
{
"shell": ["echo banana"],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
def test_shell_can_override_defaults(capfd, dotfiles, run_dotbot):
"""Verify that the shell command can override defaults."""
dotfiles.write_config(
[
{"defaults": {"shell": {"stdout": True}}},
{"shell": [{"command": "echo apple", "stdout": False}]},
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("apple") for line in stdout)
def test_shell_quiet_default(capfd, dotfiles, run_dotbot):
"""Verify that quiet is off by default."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo banana",
"description": "echoing a thing...",
}
],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
assert any("echo banana" in line for line in stdout)
assert any(line.startswith("echoing a thing...") for line in stdout)
def test_shell_quiet_enabled_with_description(capfd, dotfiles, run_dotbot):
"""Verify that only the description is shown when quiet is enabled."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo banana",
"description": "echoing a thing...",
"quiet": True,
}
],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
assert not any("echo banana" in line for line in stdout)
assert any(line.startswith("echoing a thing...") for line in stdout)
def test_shell_quiet_enabled_without_description(capfd, dotfiles, run_dotbot):
"""Verify nothing is shown when quiet is enabled with no description."""
dotfiles.write_config(
[
{
"shell": [
{
"command": "echo banana",
"quiet": True,
}
],
}
]
)
run_dotbot()
stdout = capfd.readouterr().out.splitlines()
assert not any(line.startswith("banana") for line in stdout)
assert not any(line.startswith("echo banana") for line in stdout)

49
tests/test_shim.py Normal file
View file

@ -0,0 +1,49 @@
import os
import shutil
import subprocess
import sys
import pytest
def test_shim(root, home, dotfiles, run_dotbot):
"""Verify install shim works."""
# Skip the test if git is unavailable.
git = shutil.which("git")
if git is None:
pytest.skip("git is unavailable")
if sys.platform[:5] == "win32":
install = os.path.join(
dotfiles.directory, "dotbot", "tools", "git-submodule", "install.ps1"
)
shim = os.path.join(dotfiles.directory, "install.ps1")
else:
install = os.path.join(dotfiles.directory, "dotbot", "tools", "git-submodule", "install")
shim = os.path.join(dotfiles.directory, "install")
# Set up the test environment.
git_directory = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(dotfiles.directory)
subprocess.check_call([git, "init"])
subprocess.check_call(
[git, "-c", "protocol.file.allow=always", "submodule", "add", git_directory, "dotbot"]
)
shutil.copy(install, shim)
dotfiles.write("foo", "pear")
dotfiles.write_config([{"link": {"~/.foo": "foo"}}])
# Run the shim script.
env = dict(os.environ)
if sys.platform[:5] == "win32":
args = [shutil.which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim]
env["USERPROFILE"] = home
else:
args = [shim]
env["HOME"] = home
subprocess.check_call(args, env=env, cwd=dotfiles.directory)
assert os.path.islink(os.path.join(home, ".foo"))
with open(os.path.join(home, ".foo"), "r") as file:
assert file.read() == "pear"

View file

@ -9,6 +9,7 @@ DOTBOT_BIN="bin/dotbot"
BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${BASEDIR}" cd "${BASEDIR}"
git -C "${DOTBOT_DIR}" submodule sync --quiet --recursive
git submodule update --init --recursive "${DOTBOT_DIR}" git submodule update --init --recursive "${DOTBOT_DIR}"
"${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}" -d "${BASEDIR}" -c "${CONFIG}" "${@}" "${BASEDIR}/${DOTBOT_DIR}/${DOTBOT_BIN}" -d "${BASEDIR}" -c "${CONFIG}" "${@}"

View file

@ -0,0 +1,22 @@
$ErrorActionPreference = "Stop"
$CONFIG = "install.conf.yaml"
$DOTBOT_DIR = "dotbot"
$DOTBOT_BIN = "bin/dotbot"
$BASEDIR = $PSScriptRoot
Set-Location $BASEDIR
git -C $DOTBOT_DIR submodule sync --quiet --recursive
git submodule update --init --recursive $DOTBOT_DIR
foreach ($PYTHON in ('python', 'python3')) {
# Python redirects to Microsoft Store in Windows 10 when not installed
if (& { $ErrorActionPreference = "SilentlyContinue"
![string]::IsNullOrEmpty((&$PYTHON -V))
$ErrorActionPreference = "Stop" }) {
&$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args
return
}
}
Write-Error "Error: Cannot find Python."

View file

@ -0,0 +1,21 @@
$ErrorActionPreference = "Stop"
$CONFIG = "install.conf.yaml"
$DOTBOT_DIR = "dotbot"
$DOTBOT_BIN = "bin/dotbot"
$BASEDIR = $PSScriptRoot
Set-Location $BASEDIR
Set-Location $DOTBOT_DIR && git submodule update --init --recursive
foreach ($PYTHON in ('python', 'python3')) {
# Python redirects to Microsoft Store in Windows 10 when not installed
if (& { $ErrorActionPreference = "SilentlyContinue"
![string]::IsNullOrEmpty((&$PYTHON -V))
$ErrorActionPreference = "Stop" }) {
&$PYTHON $(Join-Path $BASEDIR -ChildPath $DOTBOT_DIR | Join-Path -ChildPath $DOTBOT_BIN) -d $BASEDIR -c $CONFIG $Args
return
}
}
Write-Error "Error: Cannot find Python."

76
tox.ini Normal file
View file

@ -0,0 +1,76 @@
[tox]
; On Windows, only CPython >= 3.8 is supported.
; All older versions, and PyPy, lack full symlink support.
envlist =
coverage_erase
py{38, 39, 310, 311, 312}-all_platforms
py{36, 37}-most_platforms
pypy3-most_platforms
coverage_report
skip_missing_interpreters = true
[testenv]
platform =
all_platforms: cygwin|darwin|linux|win32
most_platforms: cygwin|darwin|linux
deps =
coverage
pytest
pytest-randomly
pyyaml
commands =
coverage run -m pytest tests/
[testenv:coverage_erase]
skipsdist = true
skip_install = true
deps = coverage
commands = coverage erase
[testenv:coverage_report]
skipsdist = true
skip_install = true
deps = coverage
commands_pre =
coverage combine
commands =
coverage report
coverage html
coverage xml
[coverage:run]
branch = true
parallel = true
source =
dotbot/
tests/
[coverage:html]
directory = htmlcov
[gh-actions]
python =
; Run on all platforms (Linux, Mac, and Windows)
3.8: py38-all_platforms
3.9: py39-all_platforms
3.10: py310-all_platforms
3.11: py311-all_platforms
3.12: py312-all_platforms
; Run on most platforms (Linux and Mac)
pypy-3.9: pypy3-most_platforms
3.6: py36-most_platforms
3.7: py37-most_platforms
; Disable problem matcher because it causes issues when running in a container;
; see https://github.com/ymyzk/tox-gh-actions/issues/126
problem_matcher = False