mirror of
1
0
Fork 0

Compare commits

...

132 Commits

Author SHA1 Message Date
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
95 changed files with 3194 additions and 1835 deletions

View File

@ -15,6 +15,3 @@ indent_size = 4
[*.yml]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

52
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,52 @@
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"
runs-on: ${{ matrix.os }}
name: "Test: Python ${{ matrix.python }} on ${{ matrix.os }}"
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- uses: actions/setup-python@v4
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

7
.gitignore vendored
View File

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

View File

@ -1,15 +0,0 @@
language: python
python:
- "2.7"
- "pypy"
- "3.4"
- "3.5"
- "3.6"
- "3.7"
- "nightly"
- "pypy3"
sudo: false
script:
- ./test/test_travis

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

@ -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
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]!

View File

@ -1,7 +1,7 @@
The MIT License (MIT)
=====================
**Copyright (c) 2014-2019 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
this software and associated documentation files (the "Software"), to deal in

219
README.md
View File

@ -1,12 +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]
---
[![Build Status](https://travis-ci.org/anishathalye/dotbot.svg?branch=master)](https://travis-ci.org/anishathalye/dotbot)
## Rationale
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
@ -17,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
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
post][managing-dotfiles-post].
See [this blog
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?
@ -57,6 +66,11 @@ cp dotbot/tools/hg-subrepo/install .
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
will take care of the rest. To help you get started we have [an
example](#full-example) config file as well as [configuration
@ -100,7 +114,6 @@ The conventional name for the configuration file is `install.conf.yaml`.
- clean: ['~']
- link:
~/.dotfiles: ''
~/.tmux.conf: tmux.conf
~/.vim: vim
~/.vimrc: vimrc
@ -114,12 +127,10 @@ The conventional name for the configuration file is `install.conf.yaml`.
```
The configuration file is typically written in YAML, but it can also be written
in JSON (which is a subset of YAML). [Here][json-equivalent] is the JSON
[equivalent][json2yaml] of the YAML configuration given above. JSON
configuration files are conventionally named `install.conf.json`.
in JSON (which is a [subset of YAML][json2yaml]). JSON configuration files are
conventionally named `install.conf.json`.
Configuration
-------------
## Configuration
Dotbot uses YAML or JSON-formatted configuration files to let you specify how
to set up your dotfiles. Currently, Dotbot knows how to [link](#link) files and
@ -142,8 +153,10 @@ 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.
Also, note that `~` in YAML is the same as `null` in JSON. If you want a single
character string containing a tilde, make sure to enclose it in quotes: `'~'`
## Directives
Most Dotbot commands support both a simplified and extended syntax, and they
can also be configured via setting [defaults](#defaults).
### Link
@ -155,23 +168,40 @@ files if necessary. Environment variables in paths are automatically expanded.
Link commands are specified as a dictionary mapping targets to source
locations. Source locations are specified relative to the base directory (that
is specified when running the installer). If linking directories, *do not* include a trailing slash.
is specified when running the installer). If linking directories, *do not*
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
mapped to extended configuration dictionaries.
Available extended configuration parameters:
| Link Option | Explanation |
| -- | -- |
| `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) |
| `glob` | Treat a `*` character as a wildcard, and perform link operations on all of those matches (default:false) |
| Parameter | Explanation |
| --- | --- |
| `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
@ -187,11 +217,22 @@ Available extended configuration parameters:
~/.zshrc:
force: true
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 config files equivalent:
the following two config files equivalent.
Explicit sources:
```yaml
- link:
@ -207,8 +248,16 @@ the following config files equivalent:
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:
@ -221,6 +270,12 @@ the following config files equivalent:
glob: true
path: config/*
relink: true
exclude: [ config/Code ]
~/.config/Code/User/:
create: true
glob: true
path: config/Code/User/*
relink: true
```
### Create
@ -231,15 +286,30 @@ apps, plugins, shell commands, etc.
#### Format
Create commands are specified as an array of directories to be created.
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:
- ~/projects
- ~/downloads
- ~/.vim/undo-history
- create:
~/.ssh:
mode: 0700
~/projects:
```
### Shell
@ -256,11 +326,22 @@ 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 commands support an extended syntax as well, which provides more
fine-grained control. A command can be specified as a dictionary that contains
the command to be run, a description, whether to suppress outputting the
command in the display via `quiet`, and whether `stdin`, `stdout`,
and `stderr` are enabled. In this syntax, all keys are optional except for the
command itself.
fine-grained control.
| Parameter | Explanation |
| --- | --- |
| `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
@ -283,17 +364,22 @@ command itself.
Clean commands specify directories that should be checked for dead symbolic
links. These dead links are removed automatically. Only dead links that point
to the dotfiles directory are removed unless the `force` option is set to
`true`.
to somewhere within the dotfiles directory are removed unless the `force`
option is set to `true`.
#### Format
Clean commands are specified as an array of directories to be cleaned.
Clean commands support an extended configuration syntax. In this type of
configuration, commands are specified as directory paths mapping to options. If
the `force` option is set to `true`, dead links are removed even if they don't
point to a file inside the dotfiles directory.
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
@ -301,8 +387,10 @@ point to a file inside the dotfiles directory.
- clean: ['~']
- clean:
~/.config:
~/:
force: true
~/.config:
recursive: true
```
### Defaults
@ -311,8 +399,8 @@ 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 follow setting the defaults. Defaults can
be set multiple times; each change replaces the defaults with a new set of
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
@ -344,40 +432,47 @@ 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.
Wiki
----
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
------------
## Contributing
Do you have a feature request, bug report, or patch? Great! See
[CONTRIBUTING.md][contributing] for information on what you can do about that.
Packaging
---------
## License
1. Update version information.
2. Build the package using ``python setup.py sdist bdist_wheel``.
3. Sign and upload the package using ``twine upload -s dist/*``.
License
-------
Copyright (c) 2014-2019 Anish Athalye. Released under the MIT License. See
Copyright (c) Anish Athalye. Released under the MIT License. See
[LICENSE.md][license] for details.
[PyPI]: https://pypi.org/project/dotbot/
[init-dotfiles]: https://github.com/Vaelatern/init-dotfiles
[dotfiles-template]: https://github.com/anishathalye/dotfiles_template
[inspiration]: https://github.com/anishathalye/dotbot/wiki/Users
[managing-dotfiles-post]: http://www.anishathalye.com/2014/08/03/managing-your-dotfiles/
[json-equivalent]: https://gist.github.com/anishathalye/84bd6ba1dbe936e05141e07ec45f5fd4
[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
[license]: LICENSE.md

View File

@ -7,9 +7,8 @@
# is useful because we don't know the name of the python binary.
''':' # begin python string; this line is interpreted by the shell as `:`
which python >/dev/null 2>&1 && exec python "$0" "$@"
which python3 >/dev/null 2>&1 && exec python3 "$0" "$@"
which python2 >/dev/null 2>&1 && exec python2 "$0" "$@"
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
'''
@ -18,6 +17,14 @@ exit 1
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(
os.path.dirname(os.path.realpath(__file__)))
@ -25,11 +32,7 @@ def inject(lib_path):
path = os.path.join(PROJECT_ROOT_DIRECTORY, 'lib', lib_path)
sys.path.insert(0, path)
# version dependent libraries
if sys.version_info[0] >= 3:
inject('pyyaml/lib3')
else:
inject('pyyaml/lib')
inject('pyyaml/lib')
if os.path.exists(os.path.join(PROJECT_ROOT_DIRECTORY, 'dotbot')):
if PROJECT_ROOT_DIRECTORY not in sys.path:

View File

@ -1,4 +1,4 @@
from .cli import main
from .plugin import Plugin
__version__ = '1.16.0'
__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,92 +1,167 @@
import os, glob
from argparse import ArgumentParser
from .config import ConfigReader, ReadingError
from .dispatcher import Dispatcher, DispatchError
from .messenger import Messenger
from .messenger import Level
from .util import module
import glob
import os
import subprocess
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
import dotbot
import yaml
from .config import ConfigReader, ReadingError
from .dispatcher import Dispatcher, DispatchError
from .messenger import Level, Messenger
from .plugins import Clean, Create, Link, Shell
from .util import module
def add_options(parser):
parser.add_argument('-Q', '--super-quiet', action='store_true',
help='suppress almost all output')
parser.add_argument('-q', '--quiet', action='store_true',
help='suppress most output')
parser.add_argument('-v', '--verbose', action='store_true',
help='enable verbose output')
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('--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(
"-Q", "--super-quiet", action="store_true", help="suppress almost all output"
)
parser.add_argument("-q", "--quiet", action="store_true", help="suppress most output")
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="enable verbose output\n"
"-v: typical verbose\n"
"-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):
reader = ConfigReader(config_file)
return reader.get_config()
def main():
log = Messenger()
try:
parser = ArgumentParser()
parser = ArgumentParser(formatter_class=RawTextHelpFormatter)
add_options(parser)
options = parser.parse_args()
if options.version:
print('Dotbot version %s (yaml: %s)' % (dotbot.__version__, yaml.__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)
if options.quiet:
log.set_level(Level.INFO)
if options.verbose:
if options.verbose > 0:
log.set_level(Level.DEBUG)
if options.no_color:
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:
from .plugins import Clean, Create, Link, Shell
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 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)
module.load(abspath)
plugins.extend(module.load(abspath))
if not options.config_file:
log.error('No configuration file specified')
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):
raise ReadingError('Configuration file must be a list of tasks')
raise ReadingError("Configuration file must be a list of tasks")
if options.base_directory:
base_directory = 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.realpath(options.config_file))
base_directory = os.path.dirname(os.path.abspath(options.config_file))
os.chdir(base_directory)
dispatcher = Dispatcher(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)
if success:
log.info('\n==> All tasks executed successfully')
log.info("\n==> All tasks executed successfully")
else:
raise DispatchError('\n==> Some tasks were not executed successfully')
raise DispatchError("\n==> Some tasks were not executed successfully")
except (ReadingError, DispatchError) as e:
log.error('%s' % e)
log.error("%s" % e)
exit(1)
except KeyboardInterrupt:
log.error('\n==> Operation aborted')
log.error("\n==> Operation aborted")
exit(1)

View File

@ -1,9 +1,12 @@
import yaml
import json
import os.path
import yaml
from .util import string
class ConfigReader(object):
class ConfigReader:
def __init__(self, config_file_path):
self._config = self._read(config_file_path)
@ -11,17 +14,18 @@ class ConfigReader(object):
try:
_, ext = os.path.splitext(config_file_path)
with open(config_file_path) as fin:
if ext == '.json':
if ext == ".json":
data = json.load(fin)
else:
data = yaml.safe_load(fin)
return data
except Exception as 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):
return self._config
class ReadingError(Exception):
pass

View File

@ -1,23 +1,33 @@
import copy
import os
from argparse import Namespace
class Context(object):
'''
class Context:
"""
Contextual data and information for plugins.
'''
"""
def __init__(self, base_directory):
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):
return self._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,48 +1,78 @@
import os
from .plugin import Plugin
from .messenger import Messenger
from argparse import Namespace
from .context import Context
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._setup_context(base_directory)
self._load_plugins()
self._setup_context(base_directory, options)
plugins = plugins or []
self._plugins = [plugin(self._context) for plugin in plugins]
self._only = only
self._skip = skip
self._exit = exit_on_failure
def _setup_context(self, base_directory):
path = os.path.abspath(os.path.realpath(
os.path.expanduser(base_directory)))
def _setup_context(self, base_directory, options):
path = os.path.abspath(os.path.expanduser(base_directory))
if not os.path.exists(path):
raise DispatchError('Nonexistent base directory')
self._context = Context(path)
raise DispatchError("Nonexistent base directory")
self._context = Context(path, options)
def dispatch(self, tasks):
success = True
for task in tasks:
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
if action == 'defaults':
self._context.set_defaults(task[action]) # replace, not update
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:
if plugin.can_handle(action):
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
except Exception as err:
self._log.error(
'An error was encountered while executing action %s' %
action)
"An error was encountered while executing action %s" % action
)
self._log.debug(err)
if self._exit:
# There was an execption exit
return False
if not handled:
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
def _load_plugins(self):
self._plugins = [plugin(self._context)
for plugin in Plugin.__subclasses__()]
class DispatchError(Exception):
pass

View File

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

View File

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

View File

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

View File

@ -1,11 +1,10 @@
import sys
from ..util.singleton import Singleton
from ..util.compat import with_metaclass
from .color import Color
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.use_color(True)
@ -16,8 +15,8 @@ class Messenger(with_metaclass(Singleton, object)):
self._use_color = yesno
def log(self, level, message):
if (level >= self._level):
print('%s%s%s' % (self._color(level), message, self._reset()))
if level >= self._level:
print("%s%s%s" % (self._color(level), message, self._reset()))
def debug(self, message):
self.log(Level.DEBUG, message)
@ -34,17 +33,14 @@ class Messenger(with_metaclass(Singleton, object)):
def error(self, message):
self.log(Level.ERROR, message)
def _should_use_color(self):
return self._use_color and sys.stdout.isatty()
def _color(self, level):
'''
"""
Get a color (terminal escape sequence) according to a level.
'''
if not self._should_use_color():
return ''
"""
if not self._use_color:
return ""
elif level < Level.DEBUG:
return ''
return ""
elif Level.DEBUG <= level < Level.LOWINFO:
return Color.YELLOW
elif Level.LOWINFO <= level < Level.INFO:
@ -57,10 +53,10 @@ class Messenger(with_metaclass(Singleton, object)):
return Color.RED
def _reset(self):
'''
"""
Get a reset color (terminal escape sequence).
'''
if not self._should_use_color():
return ''
"""
if not self._use_color:
return ""
else:
return Color.RESET

View File

@ -1,25 +1,26 @@
from .messenger import Messenger
from .context import Context
from .messenger import Messenger
class Plugin(object):
'''
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

@ -1,57 +1,72 @@
import os, dotbot
import os
import sys
class Clean(dotbot.Plugin):
'''
from ..plugin import Plugin
class Clean(Plugin):
"""
Cleans broken symbolic links.
'''
"""
_directive = 'clean'
_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)
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, {})
force = defaults.get('force', False)
for target in targets:
if isinstance(targets, dict):
force = targets[target].get('force', force)
success &= self._clean(target, force)
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')
self._log.info("All targets have been cleaned")
else:
self._log.error('Some targets were not successfully cleaned')
self._log.error("Some targets were not successfully cleaned")
return success
def _clean(self, target, force):
'''
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)
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.join(os.path.expandvars(os.path.expanduser(target)), item)
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))
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))
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), '')
"""
directory = os.path.join(os.path.realpath(directory), "")
path = os.path.realpath(path)
return os.path.commonprefix([path, directory]) == directory

View File

@ -1,53 +1,60 @@
import os
import glob
import shutil
import dotbot
import subprocess
from ..plugin import Plugin
class Create(dotbot.Plugin):
'''
class Create(Plugin):
"""
Create empty paths.
'''
"""
_directive = 'create'
_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)
raise ValueError("Create cannot handle directive %s" % directive)
return self._process_paths(data)
def _process_paths(self, paths):
success = True
for path in paths:
path = os.path.expandvars(os.path.expanduser(path))
success &= self._create(path)
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')
self._log.info("All paths have been set up")
else:
self._log.error('Some paths were not successfully set up')
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):
def _create(self, path, mode):
success = True
if not self._exists(path):
self._log.debug('Trying to create path %s' % 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)
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)
self._log.warning("Failed to create path %s" % path)
success = False
else:
self._log.lowinfo('Path exists %s' % path)
self._log.lowinfo("Path exists %s" % path)
return success

View File

@ -1,141 +1,185 @@
import os
import glob
import os
import shutil
import dotbot
import subprocess
import sys
from ..plugin import Plugin
from ..util import shell_command
class Link(dotbot.Plugin):
'''
class Link(Plugin):
"""
Symbolically links dotfiles.
'''
"""
_directive = 'link'
_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)
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', {})
defaults = self._context.defaults().get("link", {})
for destination, source in links.items():
destination = os.path.expandvars(destination)
relative = defaults.get('relative', False)
force = defaults.get('force', False)
relink = defaults.get('relink', False)
create = defaults.get('create', False)
use_glob = defaults.get('glob', False)
test = defaults.get('if', None)
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)
force = source.get('force', force)
relink = source.get('relink', relink)
create = source.get('create', create)
use_glob = source.get('glob', use_glob)
path = self._default_source(destination, source.get('path'))
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)
self._log.lowinfo("Skipping %s" % destination)
continue
path = os.path.expandvars(os.path.expanduser(path))
if use_glob:
self._log.debug("Globbing with path: " + str(path))
glob_results = glob.glob(path)
if len(glob_results) is 0:
self._log.warning("Globbing couldn't find anything matching " + str(path))
success = False
continue
glob_star_loc = path.find('*')
if glob_star_loc is -1 and destination[-1] is '/':
self._log.error("Ambiguous action requested.")
self._log.error("No wildcard in glob, directory use undefined: " +
destination + " -> " + str(glob_results))
self._log.warning("Did you want to link the directory or into it?")
success = False
continue
elif glob_star_loc is -1 and len(glob_results) is 1:
# perform a normal link operation
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(destination)
success &= self._create(glob_link_destination)
if force or relink:
success &= self._delete(path, destination, relative, force)
success &= self._link(path, destination, relative)
else:
self._log.lowinfo("Globs from '" + path + "': " + str(glob_results))
glob_base = path[:glob_star_loc]
for glob_full_item in glob_results:
glob_item = glob_full_item[len(glob_base):]
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, force)
success &= self._link(glob_full_item, glob_link_destination, relative)
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 self._exists(os.path.join(self._context.base_directory(), path)):
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))
self._log.warning("Nonexistent source %s -> %s" % (destination, path))
continue
if force or relink:
success &= self._delete(path, destination, relative, force)
success &= self._link(path, destination, relative)
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')
self._log.info("All links have been set up")
else:
self._log.error('Some links were not successfully set up')
self._log.error("Some links were not successfully set up")
return success
def _test_success(self, command):
with open(os.devnull, 'w') as devnull:
ret = subprocess.call(
command,
shell=True,
stdout=devnull,
stderr=devnull,
executable=os.environ.get('SHELL'),
)
ret = shell_command(command, cwd=self._context.base_directory())
if ret != 0:
self._log.debug('Test \'%s\' returned false' % command)
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('.'):
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)
return os.readlink(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)
@ -147,20 +191,21 @@ class Link(dotbot.Plugin):
try:
os.makedirs(parent)
except OSError:
self._log.warning('Failed to create directory %s' % parent)
self._log.warning("Failed to create directory %s" % parent)
success = False
else:
self._log.lowinfo('Creating directory %s' % parent)
self._log.lowinfo("Creating directory %s" % parent)
return success
def _delete(self, source, path, relative, force):
def _delete(self, source, path, relative, canonical_path, force):
success = True
source = os.path.join(self._context.base_directory(), source)
fullpath = os.path.expanduser(path)
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))):
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):
@ -174,65 +219,68 @@ class Link(dotbot.Plugin):
os.remove(fullpath)
removed = True
except OSError:
self._log.warning('Failed to remove %s' % path)
self._log.warning("Failed to remove %s" % path)
success = False
else:
if removed:
self._log.lowinfo('Removing %s' % path)
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):
'''
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.expanduser(link_name)
absolute_source = os.path.join(self._context.base_directory(), source)
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)))
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 self._exists(absolute_source):
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))
self._log.warning("Linking failed %s -> %s" % (link_name, source))
else:
self._log.lowinfo('Creating link %s -> %s' % (link_name, source))
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)
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)))
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))
self._log.warning("Nonexistent source %s -> %s" % (link_name, source))
else:
self._log.warning('Nonexistent source for %s : %s' %
(link_name, source))
self._log.warning("Nonexistent source for %s : %s" % (link_name, source))
else:
self._log.lowinfo('Link exists %s -> %s' % (link_name, source))
self._log.lowinfo("Link exists %s -> %s" % (link_name, source))
success = True
return success

View File

@ -1,68 +1,77 @@
import os, subprocess, dotbot
from ..plugin import Plugin
from ..util import shell_command
class Shell(dotbot.Plugin):
'''
class Shell(Plugin):
"""
Run arbitrary shell commands.
'''
"""
_directive = 'shell'
_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)
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', {})
with open(os.devnull, 'w') as devnull:
for item in data:
stdin = stdout = stderr = devnull
quiet = False
if defaults.get('stdin', False) == True:
stdin = None
if defaults.get('stdout', False) == True:
stdout = None
if defaults.get('stderr', False) == True:
stderr = None
if defaults.get('quiet', False) == True:
quiet = True
if isinstance(item, dict):
cmd = item['command']
msg = item.get('description', None)
if 'stdin' in item:
stdin = None if item['stdin'] == True else devnull
if 'stdout' in item:
stdout = None if item['stdout'] == True else devnull
if 'stderr' in item:
stderr = None if item['stderr'] == True else devnull
if 'quiet' in item:
quiet = True if item['quiet'] == True else False
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)
elif quiet:
self._log.lowinfo('%s' % msg)
else:
self._log.lowinfo('%s [%s]' % (msg, cmd))
executable = os.environ.get('SHELL')
ret = subprocess.call(cmd, shell=True, stdin=stdin, stdout=stdout,
stderr=stderr, cwd=self._context.base_directory(),
executable=executable)
if ret != 0:
success = False
self._log.warning('Command [%s] failed' % cmd)
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')
self._log.info("All commands have been executed")
else:
self._log.error('Some commands were not successfully executed')
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', (), {})

View File

@ -1,29 +1,33 @@
import sys, os.path
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)
plugin = load_module(module_name, path)
loaded_modules.append(plugin)
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
if sys.version_info >= (3, 5):
import importlib.util
def load_module(module_name, path):
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
elif sys.version_info >= (3, 3):
from importlib.machinery import SourceFileLoader
def load_module(module_name, path):
return SourceFileLoader(module_name, path).load_module()
else:
import imp
def load_module(module_name, path):
return imp.load_source(module_name, path)

View File

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

View File

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

@ -1 +1 @@
Subproject commit 7e026bfee9cc0bddeb1bbca0c4a0bcd826c2bfdf
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",
]

View File

@ -1,2 +0,0 @@
[bdist_wheel]
universal=1

View File

@ -1,87 +1,73 @@
from setuptools import setup, find_packages
from codecs import open # For a consistent encoding
from os import path
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:
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:
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)
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',
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',
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 :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Utilities',
"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',
keywords="dotfiles",
packages=find_packages(),
setup_requires=[
'setuptools>=38.6.0',
'wheel>=0.31.0',
"setuptools>=38.6.0",
"wheel>=0.31.0",
],
install_requires=[
'PyYAML>=3.12,<4',
"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',
"console_scripts": [
"dotbot=dotbot:main",
],
},
)

View File

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

1
test/.gitignore vendored
View File

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

View File

@ -1,52 +0,0 @@
Testing
=======
Dotbot testing code uses [Vagrant] to run all tests inside a virtual machine to
have tests be completely isolated from the host machine.
Installing the Test environnement
---------------------------------
### Debian-based distributions
- Install the test requirements
```bash
sudo apt install vagrant virtualbox
```
- Install Dotbot dependencies
```bash
git submodule update --init --recursive
```
### macOS
- Install the test requirements
- [VirtualBox]
- [Vagrant]
- Install Dotbot dependencies
```bash
git submodule update --init --recursive
```
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`.
Tests can be run with a specific Python version by running `./test --version
<version>` - for example, `./test --version 3.4.3`.
When finished with testing, it is good to shut down the virtual machine by
running `vagrant halt`.
[VirtualBox]: https://www.virtualbox.org/wiki/Downloads
[Vagrant]: https://www.vagrantup.com/

25
test/Vagrantfile vendored
View File

@ -1,25 +0,0 @@
Vagrant.configure(2) do |config|
config.vm.box = 'debian/buster64'
# sync by copying for isolation
config.vm.synced_folder "..", "/dotbot", type: "rsync"
# disable default synced folder
config.vm.synced_folder ".", "/vagrant", disabled: true
# install packages
config.vm.provision "shell", inline: <<-EOS
apt-get -y update
apt-get install -y git make build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \
libncurses5-dev
EOS
# install pyenv
config.vm.provision "shell", privileged: false, inline: <<-EOS
rm -rf ~/.pyenv
git clone git://github.com/yyuu/pyenv.git ~/.pyenv
echo 'export PATH="$HOME/.pyenv/bin:$PATH"\neval "$(pyenv init -)"' \
> ~/.bashrc
EOS
end

View File

@ -1,128 +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
}
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'
}
cleanup() {
vagrant ssh -c "
find . -not \\( \
-path './.pyenv' -o \
-path './.pyenv/*' -o \
-path './.bashrc' -o \
-path './.profile' -o \
-path './.ssh' -o \
-path './.ssh/*' \
\\) -delete" >/dev/null 2>&1
}
initialize() {
echo "initializing."
if ! vagrant ssh -c "pyenv local ${2}" >/dev/null 2>&1; then
if ! vagrant ssh -c "pyenv install -s ${2} && pyenv local ${2}" >/dev/null 2>&1; then
die "could not install python ${2}"
fi
fi
vagrant rsync >/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}"
cleanup
vagrant ssh -c "pyenv local ${2}" >/dev/null 2>&1
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,52 +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."
# command line options
while [[ $# > 1 ]]
do
key="${1}"
case $key in
-v|--version)
VERSION="${2}"
shift && shift
;;
*)
# unknown option
break
;;
esac
done
VERSION="${VERSION:-3.6.4}"
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[@]}" "${VERSION}"
for file in "${tests[@]}"; do
run_test "$(basename "${file}")" "${VERSION}"
done
if report; then
ret=0
else
ret=1
fi
echo "(tests run in $(($(date +%s) - start)) seconds)"
exit ${ret}

View File

@ -1,62 +0,0 @@
DEBUG=${DEBUG:-false}
USE_VAGRANT=${USE_VAGRANT:-true}
DOTBOT_EXEC=${DOTBOT_EXEC:-"python /dotbot/bin/dotbot"}
DOTFILES="/home/$(whoami)/dotfiles"
INSTALL_CONF='install.conf.yaml'
INSTALL_CONF_JSON='install.conf.json'
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() {
if ${USE_VAGRANT}; then
check_vm
fi
echo "${test_description}"
mkdir -p "${DOTFILES}"
cd
}
run_dotbot() {
(
cat > "${DOTFILES}/${INSTALL_CONF}"
${DOTBOT_EXEC} -c "${DOTFILES}/${INSTALL_CONF}" "${@}"
)
}
run_dotbot_json() {
(
cat > "${DOTFILES}/${INSTALL_CONF_JSON}"
${DOTBOT_EXEC} -c "${DOTFILES}/${INSTALL_CONF_JSON}" "${@}"
)
}
initialize

View File

@ -1,81 +0,0 @@
#!/usr/bin/env bash
set -e
# For debug only:
# export DEBUG=true
# set -x
# set -v
export BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Prevent execution outside of Travis CI builds
if [[ "${TRAVIS}" != true || "${CI}" != true ]]; then
echo "Error: `basename "$0"` should only be used on Travis"
exit 2
fi
# Travis runs do not rely on Vagrant
export USE_VAGRANT=false
export DOTBOT_EXEC="${BASEDIR}/bin/dotbot"
cd "${BASEDIR}"
. "test/driver-lib.bash"
travis_initialize() {
echo "initializing."
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}"
}
travis_cleanup() {
# Remove all dotfiles installed since the start, ignoring the main
# dotfiles directory, and the dotbot source directory
find ~ -mindepth 1 -newermt "${date_stamp}" \
-not \( -path ~ -o -path "${BASEDIR}/*" \
-o -path ~/dotfiles \) \
-exec rm -rf {} +
}
travis_run_test() {
tests_run=$((tests_run + 1))
printf '[%d/%d] (%s)\n' "${tests_run}" "${tests_total}" "${1}"
cd ${BASEDIR}/test/tests
if bash ${1} ; then
pass
else
fail
fi
travis_cleanup || die "unable to clean up system."
}
date_stamp="$(date --rfc-3339=ns)"
start="$(date +%s)"
declare -a tests=()
if [ $# -eq 0 ]; then
while read file; do
tests+=("${file}")
done < <(find ${BASEDIR}/test/tests -type f -name '*.bash')
else
tests=("$@")
fi
travis_initialize "${#tests[@]}"
for file in "${tests[@]}"; do
travis_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,16 +0,0 @@
test_description='clean expands environment variables'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s ${DOTFILES}/f ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean: ["\$HOME"]
EOF
'
test_expect_success 'test' '
! test -h ~/.f
'

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 forced to remove files linking outside dotfiles directory'
. '../test-lib.bash'
test_expect_success 'setup' '
ln -s /nowhere ~/.g
'
test_expect_success 'run' '
run_dotbot <<EOF
- clean:
~/:
force: true
EOF
'
test_expect_success 'test' '
! test -h ~/.g
'

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,20 +0,0 @@
test_description='json config with tabs allowed'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
run_dotbot_json <<EOF
[{
"link": {
"~/.i": "h"
}
}]
EOF
'
test_expect_success 'test' '
grep "grape" ~/.i
'

View File

@ -1,20 +0,0 @@
test_description='json config allowed'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "grape" > ${DOTFILES}/h
'
test_expect_success 'run' '
run_dotbot_json <<EOF
[{
"link": {
"~/.i": "h"
}
}]
EOF
'
test_expect_success 'test' '
grep "grape" ~/.i
'

View File

@ -1,23 +0,0 @@
test_description='create folders'
. '../test-lib.bash'
test_expect_success 'run' '
run_dotbot <<EOF
- create:
- ~/somedir
- ~/nested/somedir
EOF
'
test_expect_success 'test' '
[ -d ~/somedir ] &&
[ -d ~/nested/somedir ]
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- create:
- ~/somedir
- ~/nested/somedir
EOF
'

View File

@ -1,59 +0,0 @@
test_description='defaults setting works'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/f &&
ln -s ~/f ~/.f &&
ln -s /nowhere ~/.g
'
test_expect_failure 'run-fail' '
run_dotbot <<EOF
- link:
~/.f: f
EOF
'
test_expect_failure 'test-fail' '
grep "apple" ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- defaults:
link:
relink: true
- link:
~/.f: f
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f
'
test_expect_success 'run-fail 2' '
run_dotbot <<EOF
- clean: ["~"]
EOF
'
test_expect_failure 'test-fail 2' '
! test -h ~/.g
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- defaults:
clean:
force: true
- clean: ["~"]
EOF
'
test_expect_success 'test 2' '
! test -h ~/.g
'

View File

@ -1,62 +0,0 @@
test_description='can find python executable with different names'
. '../test-lib.bash'
if ${USE_VAGRANT}; then
DOTBOT_EXEC="/dotbot/bin/dotbot" # revert to calling it as a shell script
fi
# the test machine needs to have a binary named `python`
test_expect_success 'setup' '
mkdir ~/tmp_bin &&
(
IFS=:
for p in $PATH; do
find $p -maxdepth 1 -mindepth 1 -exec sh -c \
'"'"'ln -sf {} $HOME/tmp_bin/$(basename {})'"'"' \;
done
) &&
rm -f ~/tmp_bin/python &&
rm -f ~/tmp_bin/python2 &&
rm -f ~/tmp_bin/python3
'
test_expect_failure 'run' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'
test_expect_success 'setup 2' '
touch ~/tmp_bin/python &&
chmod +x ~/tmp_bin/python &&
cat >> ~/tmp_bin/python <<EOF
#!$HOME/tmp_bin/bash
exec $(which python)
EOF
'
test_expect_success 'run 2' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'
test_expect_success 'setup 3' '
mv ~/tmp_bin/python ~/tmp_bin/python2
'
test_expect_success 'run 3' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'
test_expect_success 'setup 4' '
mv ~/tmp_bin/python2 ~/tmp_bin/python3
'
test_expect_success 'run 4' '
PATH="$HOME/tmp_bin" run_dotbot <<EOF
[]
EOF
'

View File

@ -1,26 +0,0 @@
test_description='link uses destination if source is null'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ${DOTFILES}/fd
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/f:
~/.f:
~/fd:
force: false
~/.fd:
force: false
EOF
'
test_expect_success 'test' '
grep "apple" ~/f &&
grep "apple" ~/.f &&
grep "grape" ~/fd &&
grep "grape" ~/.fd
'

View File

@ -1,17 +0,0 @@
test_description='link expands user in target'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ~/f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/g: ~/f
EOF
'
test_expect_success 'test' '
grep "apple" ~/g
'

View File

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

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,24 +0,0 @@
test_description='force leaves file when target nonexistent'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ~/dir &&
touch ~/file
'
test_expect_failure 'run' '
run_dotbot <<EOF
- link:
~/dir:
path: dir
force: true
~/file:
path: file
force: true
EOF
'
test_expect_success 'test' '
test -d ~/dir &&
test -f ~/file
'

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,45 +0,0 @@
test_description='link glob ambiguous'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/foo
'
test_expect_failure 'run 1' '
run_dotbot <<EOF
- link:
~/foo/:
path: foo
glob: true
EOF
'
test_expect_failure 'test 1' '
test -d ~/foo
'
test_expect_failure 'run 2' '
run_dotbot <<EOF
- link:
~/foo/:
path: foo/
glob: true
EOF
'
test_expect_failure 'test 2' '
test -d ~/foo
'
test_expect_success 'run 3' '
run_dotbot <<EOF
- link:
~/foo:
path: foo
glob: true
EOF
'
test_expect_success 'test 3' '
test -d ~/foo
'

View File

@ -1,31 +0,0 @@
test_description='link glob'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/config &&
mkdir ${DOTFILES}/config/foo &&
mkdir ${DOTFILES}/config/bar &&
echo "apple" > ${DOTFILES}/config/foo/a &&
echo "banana" > ${DOTFILES}/config/bar/b &&
echo "cherry" > ${DOTFILES}/config/bar/c
'
test_expect_success 'run' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/.config/: config/*/*
EOF
'
test_expect_success 'test' '
! readlink ~/.config/ &&
! readlink ~/.config/foo &&
readlink ~/.config/foo/a &&
grep "apple" ~/.config/foo/a &&
grep "banana" ~/.config/bar/b &&
grep "cherry" ~/.config/bar/c
'

View File

@ -1,47 +0,0 @@
test_description='link glob'
. '../test-lib.bash'
test_expect_success 'setup 1' '
mkdir ${DOTFILES}/bin &&
echo "apple" > ${DOTFILES}/bin/a &&
echo "banana" > ${DOTFILES}/bin/b &&
echo "cherry" > ${DOTFILES}/bin/c
'
test_expect_success 'run 1' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/bin: bin/*
EOF
'
test_expect_success 'test 1' '
grep "apple" ~/bin/a &&
grep "banana" ~/bin/b &&
grep "cherry" ~/bin/c
'
test_expect_success 'setup 2' '
rm -rf ~/bin
'
test_expect_success 'run 2' '
run_dotbot -v <<EOF
- defaults:
link:
glob: true
create: true
- link:
~/bin/: bin/*
EOF
'
test_expect_success 'test 2' '
grep "apple" ~/bin/a &&
grep "banana" ~/bin/b &&
grep "cherry" ~/bin/c
'

View File

@ -1,51 +0,0 @@
test_description='link if'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ~/d
echo "apple" > ${DOTFILES}/f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
if: "true"
~/.g:
path: f
if: "false"
~/.h:
path: f
if: "[[ -d ~/d ]]"
~/.i:
path: f
if: "badcommand"
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f &&
! test -f ~/.g &&
grep "apple" ~/.h &&
! test -f ~/.i
'
test_expect_success 'run 2' '
run_dotbot <<EOF
- defaults:
link:
if: "false"
- link:
~/.j:
path: f
if: "true"
~/.k:
path: f
EOF
'
test_expect_success 'test 2' '
grep "apple" ~/.j &&
! test -f ~/.k
'

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,36 +0,0 @@
test_description='relative linking works'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
mkdir ${DOTFILES}/d &&
echo "grape" > ${DOTFILES}/d/e
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.f:
path: f
~/.frel:
path: f
relative: true
~/nested/.frel:
path: f
create: true
relative: true
~/.d:
path: d
relative: true
EOF
'
test_expect_success 'test' '
grep "apple" ~/.f &&
grep "apple" ~/.frel &&
[[ "$(readlink ~/.f)" == "$(readlink -f dotfiles/f)" ]] &&
[[ "$(readlink ~/.frel)" == "dotfiles/f" ]] &&
[[ "$(readlink ~/nested/.frel)" == "../dotfiles/f" ]] &&
grep "grape" ~/.d/e &&
[[ "$(readlink ~/.d)" == "dotfiles/d" ]]
'

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,32 +0,0 @@
test_description='relink relative does not incorrectly relink file'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f &&
echo "grape" > ~/.f
'
test_expect_success 'run' '
run_dotbot <<EOF
- link:
~/.folder/f:
path: f
create: true
relative: true
EOF
'
# these are done in a single block because they run in a subshell, and it
# wouldn't be possible to access `$mtime` outside of the subshell
test_expect_success 'test' '
mtime=$(stat ~/.folder/f | grep Modify)
run_dotbot <<EOF
- link:
~/.folder/f:
path: f
create: true
relative: true
relink: true
EOF
[[ "$mtime" == "$(stat ~/.folder/f | grep Modify)" ]]
'

View File

@ -1,29 +0,0 @@
test_description='directory-based plugin loading works'
. '../test-lib.bash'
test_expect_success 'setup' '
mkdir ${DOTFILES}/plugins
cat > ${DOTFILES}/plugins/test.py <<EOF
import dotbot
import os.path
class Test(dotbot.Plugin):
def can_handle(self, directive):
return directive == "test"
def handle(self, directive, data):
with open(os.path.expanduser("~/flag"), "w") as f:
f.write("it works")
return True
EOF
'
test_expect_success 'run' '
run_dotbot --plugin-dir ${DOTFILES}/plugins <<EOF
- test: ~
EOF
'
test_expect_success 'test' '
grep "it works" ~/flag
'

View File

@ -1,17 +0,0 @@
test_description='can disable built-in plugins'
. '../test-lib.bash'
test_expect_success 'setup' '
echo "apple" > ${DOTFILES}/f
'
test_expect_failure 'run' '
run_dotbot --disable-built-in-plugins <<EOF
- link:
~/.f: f
EOF
'
test_expect_failure 'test' '
test -f ~/.f
'

View File

@ -1,28 +0,0 @@
test_description='plugin loading works'
. '../test-lib.bash'
test_expect_success 'setup' '
cat > ${DOTFILES}/test.py <<EOF
import dotbot
import os.path
class Test(dotbot.Plugin):
def can_handle(self, directive):
return directive == "test"
def handle(self, directive, data):
with open(os.path.expanduser("~/flag"), "w") as f:
f.write("it works")
return True
EOF
'
test_expect_success 'run' '
run_dotbot --plugin ${DOTFILES}/test.py <<EOF
- test: ~
EOF
'
test_expect_success 'test' '
grep "it works" ~/flag
'

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,22 +0,0 @@
test_description='shell command stdout works in compact form'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | grep "^apple") <<EOF
- defaults:
shell:
stdout: true
- shell:
- echo apple
EOF
'
test_expect_success 'run 2' '
(run_dotbot | grep "^apple") <<EOF
- defaults:
shell:
stdout: true
- shell:
- [echo apple, "echoing message"]
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
'

View File

@ -1,14 +0,0 @@
test_description='shell command can override default'
. '../test-lib.bash'
test_expect_success 'run' '
(run_dotbot | (! grep "^apple")) <<EOF
- defaults:
shell:
stdout: true
- shell:
-
command: echo apple
stdout: false
EOF
'

View File

@ -1,30 +0,0 @@
test_description='shell command can be suppressed in output'
. '../test-lib.bash'
# when not quiet, expect to see command that was run
test_expect_success 'run' '
(run_dotbot | grep "echo banana") <<EOF
- shell:
- command: echo banana
description: echoing a thing...
EOF
'
# when quiet, expect command to be suppressed
test_expect_success 'run 2' '
(run_dotbot | (! grep "echo banana")) <<EOF
- shell:
- command: echo banana
description: echoing a thing...
quiet: true
EOF
'
# when no description, expect to see command
test_expect_success 'run 3' '
(run_dotbot | grep "echo banana") <<EOF
- shell:
- command: echo banana
quiet: true
EOF
'

View File

@ -1,29 +0,0 @@
test_description='install shim works'
. '../test-lib.bash'
test_expect_success 'setup' '
cd ${DOTFILES}
git init
if ${USE_VAGRANT}; then
git submodule add /dotbot dotbot
else
git submodule add ${BASEDIR} dotbot
fi
cp ./dotbot/tools/git-submodule/install .
echo "pear" > ${DOTFILES}/foo
'
test_expect_success 'run' '
cat > ${DOTFILES}/install.conf.yaml <<EOF
- link:
~/.foo: foo
EOF
if ! ${USE_VAGRANT}; then
sed -i "" "1 s/sh$/python/" ${DOTFILES}/dotbot/bin/dotbot
fi
${DOTFILES}/install
'
test_expect_success 'test' '
grep "pear" ~/.foo
'

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

@ -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