Skip to content

Commit 99ece2d

Browse files
committed
Initial commit
0 parents  commit 99ece2d

19 files changed

+1258
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*.egg-info/
2+
*.pyc
3+
*.pyo
4+
.tox/

.travis.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
language: python
2+
services:
3+
- docker
4+
python:
5+
- 2.7
6+
- 3.5
7+
- 3.6
8+
matrix:
9+
include:
10+
- python: 3.7
11+
dist: xenial
12+
- python: 3.8
13+
dist: xenial
14+
install:
15+
- pip freeze
16+
- pip install -r requirements-dev.txt
17+
- pip install coveralls
18+
- pip freeze
19+
script:
20+
- pylint chmod_monkey
21+
- coverage run --source=chmod_monkey -m pytest
22+
- python setup.py build
23+
after_success:
24+
- coveralls

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
History
2+
=======
3+
4+
1.0.0 (unreleased)
5+
-------------------------
6+
7+
- First version

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2020 Rémi Alvergnat
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

MANIFEST.in

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
include *.in
2+
include *.ini
3+
include *.md
4+
include *.txt
5+
include *rc
6+
include LICENSE
7+
recursive-include .travis *
8+
recursive-include tests *
9+
recursive-include chmod_monkey *
10+
recursive-exclude tests *.pyc
11+
recursive-exclude tests *.pyo
12+
recursive-exclude chmod_monkey *.pyc
13+
recursive-exclude chmod_monkey *.pyo

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# python-chmod-monkey
2+
3+
[![PyPI](https://img.shields.io/pypi/v/chmod-monkey)](https://pypi.org/project/chmod-monkey/)
4+
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chmod-monkey)
5+
![PyPI - License](https://img.shields.io/pypi/l/chmod-monkey)
6+
[![Build Status](https://img.shields.io/travis/Toilal/python-chmod-monkey.svg)](https://travis-ci.org/Toilal/python-chmod-monkey)
7+
[![Code coverage](https://img.shields.io/coveralls/github/Toilal/python-chmod-monkey)](https://coveralls.io/github/Toilal/python-chmod-monkey)
8+
9+
Add support for `os.chmod('script.sh', 'ug+x')` syntax style.
10+
11+
Almost any expression supported by [GNU Coreutils chmod](https://linux.die.net/man/1/chmod) should be supported by this module.
12+
13+
**`[ugoa]*([-+=]([rwx]*|[ugo]))+|[-+=][0-7]+`**
14+
15+
`Xst` flags are not supported though.
16+
17+
## Install
18+
19+
```
20+
pip install chmod-monkey
21+
```
22+
23+
## Usage
24+
25+
There are two ways to use `chmod-monkey`.
26+
27+
### Using os.chmod MonkeyPatch
28+
29+
```python
30+
import os
31+
32+
import chmod_monkey
33+
chmod_monkey.install() # Install monkeypatch because we are evil !
34+
35+
os.chmod('script.sh', 'ug+x') # Magic :)
36+
```
37+
38+
### Using to_mode converter
39+
40+
```python
41+
import os
42+
43+
from chmod_monkey import to_mode
44+
45+
os.chmod('script.sh', to_mode('ug+x')) # For serious people.
46+
```

chmod_monkey/__init__.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Add support for `os.chmod('script.sh', 'ug+x')` syntax style.
3+
"""
4+
5+
import os
6+
7+
from .parser import parse
8+
9+
_default_chmod = None # pylint:disable=invalid-name
10+
11+
12+
def to_mode(filepath, mode_str):
13+
"""
14+
Convert a string based mode to a bitmask mode ready to give to os.chmod.
15+
:param filepath:
16+
:param mode_str:
17+
:return:
18+
"""
19+
mode = None
20+
for parsed in parse(mode_str):
21+
mode = parsed.to_mode(filepath, mode)
22+
return mode
23+
24+
25+
def install():
26+
"""
27+
Monkeypatch os.chmod to support string input.
28+
"""
29+
global _default_chmod # pylint:disable=global-statement,invalid-name
30+
if _default_chmod is None:
31+
_default_chmod = os.chmod
32+
33+
def chmod_monkey(*args, **kwargs):
34+
"""
35+
os.chmod MonkeyPatch decorator function. Invoke to_mode if mode argument is not an int.
36+
"""
37+
if len(args) > 0: # pylint:disable=len-as-condition
38+
path = args[0]
39+
else:
40+
path = kwargs.get('path')
41+
42+
if len(args) > 1:
43+
mode = args[1]
44+
else:
45+
mode = kwargs.get('mode')
46+
47+
if path is not None and not isinstance(mode, int):
48+
mode = to_mode(path, mode)
49+
if len(args) > 1:
50+
args_list = list(args)
51+
args_list[1] = mode
52+
args = tuple(args_list)
53+
else:
54+
kwargs['mode'] = mode
55+
return _default_chmod(*args, **kwargs)
56+
57+
os.chmod = chmod_monkey

chmod_monkey/__version__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# -*- coding: utf-8 -*-
2+
# pragma: no cover
3+
"""
4+
Version
5+
"""
6+
__version__ = '1.0.0.dev0'

chmod_monkey/operation.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Operation
3+
"""
4+
5+
import os
6+
import stat
7+
8+
BITS = "0123456789"
9+
FLAGS = "rwxXst"
10+
COPY = "ugo"
11+
12+
_BIT_MAPPING = {
13+
"a": {
14+
"r": stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH,
15+
"w": stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH,
16+
"x": stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
17+
},
18+
"u": {
19+
"r": stat.S_IRUSR,
20+
"w": stat.S_IWUSR,
21+
"x": stat.S_IXUSR
22+
},
23+
"g": {
24+
"r": stat.S_IRGRP,
25+
"w": stat.S_IWGRP,
26+
"x": stat.S_IXGRP
27+
},
28+
"o": {
29+
"r": stat.S_IROTH,
30+
"w": stat.S_IWOTH,
31+
"x": stat.S_IXOTH
32+
}
33+
}
34+
35+
36+
class Operation(object):
37+
"""
38+
An operation to perform on file permissions.
39+
"""
40+
41+
def __init__(self, source=None, operator=None, target=None):
42+
self.source = source
43+
self.operator = operator
44+
self.target = target
45+
46+
@property
47+
def target_mode(self):
48+
"""
49+
Retrieve target mode from target
50+
"""
51+
if isinstance(self.target, int):
52+
return TARGET_MODE_BITS
53+
if self.target is not None and self.target:
54+
if self.target[0] in BITS:
55+
return TARGET_MODE_BITS
56+
if self.target[0] in FLAGS:
57+
return TARGET_MODE_FLAGS
58+
if self.target[0] in COPY:
59+
return TARGET_MODE_COPY
60+
return None
61+
62+
def to_mode(self, filepath=None, mode=None):
63+
"""
64+
Converts the operation to mode ready to apply with os.chmod
65+
:param filepath: actual filepath. Used to get actual mode if not given.
66+
:param mode: actual mode.
67+
"""
68+
if self.operator != '=':
69+
if mode is None:
70+
if not filepath:
71+
raise ValueError(
72+
"filepath or mode parameter must be defined for \"%s\" operator" % (self.operator,))
73+
mode = stat.S_IMODE(os.lstat(filepath).st_mode)
74+
else:
75+
mode = 0
76+
77+
if self.target_mode == TARGET_MODE_FLAGS:
78+
return self._flags_to_mode(mode)
79+
if self.target_mode == TARGET_MODE_COPY:
80+
return self._copy_to_mode(mode)
81+
if self.target_mode == TARGET_MODE_BITS:
82+
return self._bits_to_mode(mode)
83+
raise ValueError("Target mode value is invalid (%i)" % (self.target_mode,))
84+
85+
def _flags_to_mode(self, mode):
86+
for source in self.source:
87+
for target in self.target:
88+
bit = _BIT_MAPPING[source][target]
89+
if self.operator == '-':
90+
mode = mode & ~ bit
91+
else:
92+
mode = mode | bit
93+
return mode
94+
95+
def _copy_to_mode(self, mode):
96+
for source in self.source:
97+
for target in self.target:
98+
flags = ''
99+
for flag in 'rwx':
100+
try:
101+
target_bit = _BIT_MAPPING[target][flag]
102+
if bool(mode & target_bit):
103+
flags += flag
104+
except KeyError:
105+
continue
106+
for flag in flags:
107+
bit = _BIT_MAPPING[source][flag]
108+
if self.operator == '-':
109+
mode = mode & ~ bit
110+
else:
111+
mode = mode | bit
112+
return mode
113+
114+
def _bits_to_mode(self, mode):
115+
if self.operator == '-':
116+
return mode & ~ self.target # pylint:disable=invalid-unary-operand-type
117+
return mode | self.target
118+
119+
def clone(self):
120+
"""
121+
Clone the operation
122+
"""
123+
return Operation(source=self.source, operator=self.operator, target=self.target)
124+
125+
def __repr__(self):
126+
return ''.join([x for x in (self.source, self.operator, self.target) if x is not None])
127+
128+
def __hash__(self):
129+
return hash(self.source) + hash(self.operator) + hash(self.target)
130+
131+
def __eq__(self, other):
132+
return self.__class__ == other.__class__ and \
133+
self.source == other.source and \
134+
self.operator == other.operator and \
135+
self.target == other.target
136+
137+
138+
TARGET_MODE_COPY = 2
139+
TARGET_MODE_FLAGS = 1
140+
TARGET_MODE_BITS = 0

0 commit comments

Comments
 (0)