mirror of
https://github.com/Hopiu/django-imagekit.git
synced 2026-03-17 13:50:24 +00:00
Compare commits
543 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
238573051e | ||
|
|
417e33ff5a | ||
|
|
9d450a78b8 | ||
|
|
bc12a319b3 | ||
|
|
85f0741594 | ||
|
|
3317273401 | ||
|
|
94cc8ed9e4 | ||
|
|
60f35b0af5 | ||
|
|
2c85d5aafe | ||
|
|
f3c5f7cb16 | ||
|
|
66db460c24 | ||
|
|
6f7de35f79 | ||
|
|
de991d4048 | ||
|
|
595f7b35ef | ||
|
|
fc221335b7 | ||
|
|
58e44975c7 | ||
|
|
115b596a8d | ||
|
|
ea66e3d10d | ||
|
|
6319891697 | ||
|
|
6ee931398f | ||
|
|
7e23384145 | ||
|
|
d80f426d3c | ||
|
|
c95542ee2a | ||
|
|
de3047e73d | ||
|
|
a153812add | ||
|
|
364cd49278 | ||
|
|
2e1b574486 | ||
|
|
3819e61fdb | ||
|
|
845eeab3ce | ||
|
|
755bd34c3e | ||
|
|
2b04099dc4 | ||
|
|
c3dbb1edf0 | ||
|
|
681b85d7bf | ||
|
|
f96dadbfe0 | ||
|
|
499e9e1e07 | ||
|
|
36fa53e249 | ||
|
|
c74d8424b8 | ||
|
|
3d37fb3d3a | ||
|
|
c24455ef36 | ||
|
|
934a5283ad | ||
|
|
5281859d60 | ||
|
|
46d2a9e663 | ||
|
|
855c9a32b1 | ||
|
|
16ab0d2c99 | ||
|
|
96383451a0 | ||
|
|
755193699b | ||
|
|
d3369eec89 | ||
|
|
12fdee81dd | ||
|
|
dee14b6c22 | ||
|
|
2bc6241f55 | ||
|
|
3546c39178 | ||
|
|
4d1ee41f2e | ||
|
|
f6d3cbe4a1 | ||
|
|
48cf03b482 | ||
|
|
175904617e | ||
|
|
732f7045e4 | ||
|
|
95e484d073 | ||
|
|
47ff56cfe2 | ||
|
|
c354bb365a | ||
|
|
7e4bf0e3d8 | ||
|
|
f1f295e054 | ||
|
|
6457cf0c55 | ||
|
|
6a8fe5f83c | ||
|
|
3c0c47d8ed | ||
|
|
d86ec082f1 | ||
|
|
23a243c51e | ||
|
|
07d29b3bf7 | ||
|
|
5061679b17 | ||
|
|
5cde74e3e2 | ||
|
|
e9425df833 | ||
|
|
f98ee822a4 | ||
|
|
52ad8a0ace | ||
|
|
f2255a5d3a | ||
|
|
03a8d0d443 | ||
|
|
b460a66874 | ||
|
|
124b23ccc8 | ||
|
|
371a3bb376 | ||
|
|
b3084b43b2 | ||
|
|
4e370fdc59 | ||
|
|
7ddca36712 | ||
|
|
340e26cd67 | ||
|
|
5ce8b9f072 | ||
|
|
d280ad8989 | ||
|
|
0c435539df | ||
|
|
7903efd9b7 | ||
|
|
53fb3a8722 | ||
|
|
d1e877f07d | ||
|
|
cec8cd7780 | ||
|
|
e79d2ba60e | ||
|
|
97dc4b6cb2 | ||
|
|
6fabad9749 | ||
|
|
b475de7b48 | ||
|
|
820d2f00eb | ||
|
|
c89a63edbe | ||
|
|
ecf5e892e2 | ||
|
|
c858936e0c | ||
|
|
7f36f897f8 | ||
|
|
5855e97997 | ||
|
|
e155b632cd | ||
|
|
fbf15befb8 | ||
|
|
0a0708d2d6 | ||
|
|
673b95b4c4 | ||
|
|
75763b80f8 | ||
|
|
71e2a5b802 | ||
|
|
7cdda46070 | ||
|
|
eb81b9c88c | ||
|
|
9e5ef330fa | ||
|
|
458f80050c | ||
|
|
e455768352 | ||
|
|
db70f810ad | ||
|
|
561b5d7ab7 | ||
|
|
6bb45bc532 | ||
|
|
d6bbff47f0 | ||
|
|
41f45a4fe7 | ||
|
|
8ad3d1e8be | ||
|
|
f6e0033aae | ||
|
|
207849e48e | ||
|
|
c5738740fb | ||
|
|
a159e7c75b | ||
|
|
d9fe8d24b2 | ||
|
|
5275d613e6 | ||
|
|
1d5606b3d7 | ||
|
|
7f40d4fd4b | ||
|
|
94255855db | ||
|
|
1ac3399737 | ||
|
|
e56f8c5925 | ||
|
|
5f4f7070f4 | ||
|
|
3a2150e515 | ||
|
|
e2ae850866 | ||
|
|
b9b95717c6 | ||
|
|
78a1ccaf2f | ||
|
|
8d35dad5fc | ||
|
|
002b5bdac8 | ||
|
|
bbf48a7953 | ||
|
|
00b4388245 | ||
|
|
c92f53c1b0 | ||
|
|
9f4192a7c6 | ||
|
|
f5b23a67bd | ||
|
|
945a5623ef | ||
|
|
06b06dbced | ||
|
|
89b5666b02 | ||
|
|
d013b82c7f | ||
|
|
2f7bfe5dc7 | ||
|
|
5bb41bdccd | ||
|
|
1d80e83732 | ||
|
|
1448e7dddd | ||
|
|
3056b3efc0 | ||
|
|
f45491bddb | ||
|
|
b869f78b35 | ||
|
|
6aa99adf1c | ||
|
|
9be8507ebd | ||
|
|
5b0c789f6b | ||
|
|
c5a1be3b8e | ||
|
|
85d8cb15bb | ||
|
|
d3aabb0db3 | ||
|
|
169b594b28 | ||
|
|
df8d798551 | ||
|
|
3799f3c2f4 | ||
|
|
ffd3ba384e | ||
|
|
14c7979e4c | ||
|
|
1ac1a44fc5 | ||
|
|
f113fc7517 | ||
|
|
ce9a62c02c | ||
|
|
2ff015a89a | ||
|
|
452a9c1b31 | ||
|
|
3667c09d82 | ||
|
|
26aa19eeef | ||
|
|
8a600d30b3 | ||
|
|
45f10075b6 | ||
|
|
87983c5e6d | ||
|
|
c1e16696b1 | ||
|
|
fb947b1937 | ||
|
|
68cfcce3f1 | ||
|
|
43afb7c33d | ||
|
|
687884224c | ||
|
|
af3316278d | ||
|
|
260c6f5a10 | ||
|
|
2ed1855aa1 | ||
|
|
d6a024ed2d | ||
|
|
5a218e1465 | ||
|
|
1c26a2ea5c | ||
|
|
857b1e160e | ||
|
|
bf1b45c943 | ||
|
|
2e4d435f4f | ||
|
|
3732b2ee09 | ||
|
|
183efabca7 | ||
|
|
0575011529 | ||
|
|
453efac553 | ||
|
|
c78cbfc089 | ||
|
|
d647678c2e | ||
|
|
1a33c2be51 | ||
|
|
bc8fdd7ada | ||
|
|
144c388689 | ||
|
|
3be774bbf6 | ||
|
|
532188bd51 | ||
|
|
e36290b4ee | ||
|
|
8a709a845c | ||
|
|
05ec0c1b33 | ||
|
|
cae6677994 | ||
|
|
3444626084 | ||
|
|
a0c7b3f274 | ||
|
|
f684b4e4e0 | ||
|
|
14939faef6 | ||
|
|
3c04cb852f | ||
|
|
db6cfcb6ce | ||
|
|
637af70921 | ||
|
|
ada883c99f | ||
|
|
ef05e23b66 | ||
|
|
90e9d314a6 | ||
|
|
bb7e9e5891 | ||
|
|
01a6c555a1 | ||
|
|
3001069254 | ||
|
|
654867c3cf | ||
|
|
6582794408 | ||
|
|
d3882c34b2 | ||
|
|
02cd1bf7ff | ||
|
|
af73a93953 | ||
|
|
0e41675d1c | ||
|
|
0e0240085f | ||
|
|
09eea6edf6 | ||
|
|
33b8913031 | ||
|
|
761dcd20ae | ||
|
|
f9d91c7c4d | ||
|
|
535e68aea6 | ||
|
|
404fed58ec | ||
|
|
c6a0a13c45 | ||
|
|
a6ef72027b | ||
|
|
4ff55724dc | ||
|
|
397a79ba56 | ||
|
|
0d5bfe3751 | ||
|
|
80008aee11 | ||
|
|
32522114db | ||
|
|
6023e9216a | ||
|
|
34739819aa | ||
|
|
c89b18aa95 | ||
|
|
6db082bca2 | ||
|
|
906fbbd463 | ||
|
|
bc49f9cf8b | ||
|
|
06dd238993 | ||
|
|
d5d5fc0550 | ||
|
|
4efa05099d | ||
|
|
82eb69b3be | ||
|
|
92a9184ed3 | ||
|
|
0de80cf59c | ||
|
|
2304381b3d | ||
|
|
6b75822eb3 | ||
|
|
84dcebbefe | ||
|
|
676d49c605 | ||
|
|
2064434042 | ||
|
|
d94c4bb0a9 | ||
|
|
4a608caf3a | ||
|
|
deed81b963 | ||
|
|
eb36ae399e | ||
|
|
f173861b53 | ||
|
|
c704db2da5 | ||
|
|
bc0c17010e | ||
|
|
a3498c5aa1 | ||
|
|
f5b171979b | ||
|
|
3e019f5dac | ||
|
|
0a98eb0e79 | ||
|
|
13c92db760 | ||
|
|
7946fe54b5 | ||
|
|
90c4529d26 | ||
|
|
261f164127 | ||
|
|
c4431fe296 | ||
|
|
4b2e6341f9 | ||
|
|
6f9f99e86c | ||
|
|
091b2137d0 | ||
|
|
41ab2c0fa3 | ||
|
|
dafebc9a4d | ||
|
|
9891314b8e | ||
|
|
681527fa9d | ||
|
|
c0ce3b5209 | ||
|
|
28ffd444d6 | ||
|
|
fbf052ce5f | ||
|
|
2b10e85813 | ||
|
|
3ca8c53698 | ||
|
|
728368abf6 | ||
|
|
86cd23e906 | ||
|
|
3d6a9de9aa | ||
|
|
b061e135c2 | ||
|
|
1e129c5b70 | ||
|
|
70ff6dc788 | ||
|
|
c48c720f8a | ||
|
|
fc87c0497c | ||
|
|
54dda25adb | ||
|
|
ac92b77709 | ||
|
|
f181d30008 | ||
|
|
044a3625f9 | ||
|
|
aae6aeb142 | ||
|
|
d62d8a824e | ||
|
|
14be033b7f | ||
|
|
c64eee40b3 | ||
|
|
92a3c2688c | ||
|
|
61aa1c32e7 | ||
|
|
b33869a9f8 | ||
|
|
190153d068 | ||
|
|
74ae51f164 | ||
|
|
dff0182310 | ||
|
|
f5a078b68f | ||
|
|
e456e0fd99 | ||
|
|
896e289884 | ||
|
|
5c944c5efb | ||
|
|
b180bbef09 | ||
|
|
b53b7c3cae | ||
|
|
90ae9e8339 | ||
|
|
7bf3e4e7a3 | ||
|
|
de550b71df | ||
|
|
df41459e65 | ||
|
|
af6ebcb469 | ||
|
|
969275bbc9 | ||
|
|
9f3550a4f4 | ||
|
|
98552d7f75 | ||
|
|
ec5d4ed324 | ||
|
|
36313194ac | ||
|
|
d0ba353be3 | ||
|
|
aa2dcb8e34 | ||
|
|
8fabbae86c | ||
|
|
561856abd7 | ||
|
|
51dcf283fd | ||
|
|
80a1f0b4ee | ||
|
|
4ac6565bec | ||
|
|
7759394df7 | ||
|
|
c90e6d637c | ||
|
|
5982e1e549 | ||
|
|
f6ce251e13 | ||
|
|
a906555c9f | ||
|
|
088b84627b | ||
|
|
1f86e33d64 | ||
|
|
0ea497261b | ||
|
|
34a7ab9751 | ||
|
|
55a2a5fc9d | ||
|
|
301adc2087 | ||
|
|
59971b6cd4 | ||
|
|
5f8f651def | ||
|
|
d22c49a465 | ||
|
|
1a7c0627df | ||
|
|
36c0757417 | ||
|
|
218f569005 | ||
|
|
c9205e588e | ||
|
|
58e1c7f7e0 | ||
|
|
f94b7276b3 | ||
|
|
50d83745bc | ||
|
|
08ebcbcbf3 | ||
|
|
92b11f8349 | ||
|
|
0947c1403f | ||
|
|
933ff79ac1 | ||
|
|
e1c819e9b4 | ||
|
|
bf1685dbfb | ||
|
|
2ca4e4b6c2 | ||
|
|
75962976d0 | ||
|
|
d6b73b8da7 | ||
|
|
8e6abc1e65 | ||
|
|
01fad6e4c6 | ||
|
|
04aa72c1f9 | ||
|
|
7f6188623c | ||
|
|
f0dbe32f7a | ||
|
|
54ca5da15d | ||
|
|
e0ffb246ae | ||
|
|
e48817a5ec | ||
|
|
ca4f090e63 | ||
|
|
3931b552a0 | ||
|
|
5b44564318 | ||
|
|
a3e9a080d4 | ||
|
|
cef3a41d86 | ||
|
|
eb9089e0c8 | ||
|
|
c202234e82 | ||
|
|
b45a22abe6 | ||
|
|
a8855d4c27 | ||
|
|
f4917ab7ca | ||
|
|
84b30e990f | ||
|
|
c6f2c2e7a7 | ||
|
|
234082e63c | ||
|
|
9dd7bef709 | ||
|
|
d52b9c8100 | ||
|
|
4737ac64c4 | ||
|
|
6ff1d35fbe | ||
|
|
eef1e41448 | ||
|
|
d632fc70fa | ||
|
|
4ecfa5d35e | ||
|
|
8c5a571293 | ||
|
|
e5b15d09bd | ||
|
|
5acce98223 | ||
|
|
219b8507ad | ||
|
|
43a1f49498 | ||
|
|
3177eb8e19 | ||
|
|
11d511f9cc | ||
|
|
658bb22c78 | ||
|
|
c2dedaa2b8 | ||
|
|
faee0fa537 | ||
|
|
d80f2f26a9 | ||
|
|
184c13dd4e | ||
|
|
52fb4e24be | ||
|
|
30e40b4916 | ||
|
|
c69c2d087e | ||
|
|
1fb1d83c56 | ||
|
|
8c80ba3b4f | ||
|
|
12307c97aa | ||
|
|
042bdcefb6 | ||
|
|
c45876f95c | ||
|
|
2a6199b804 | ||
|
|
7578903307 | ||
|
|
0ec6067c8d | ||
|
|
938e2e178b | ||
|
|
7f11f44c67 | ||
|
|
ea962b6259 | ||
|
|
afc5900db6 | ||
|
|
956601b5d0 | ||
|
|
a07bc49a25 | ||
|
|
a5c33a4925 | ||
|
|
a499f5fbe6 | ||
|
|
4f7ce68904 | ||
|
|
4f81e14f58 | ||
|
|
848d7d7fa3 | ||
|
|
14d2193f8d | ||
|
|
db777594ce | ||
|
|
236eea8459 | ||
|
|
20c900df4a | ||
|
|
7bc82d3624 | ||
|
|
5ecb491e65 | ||
|
|
7ed404f096 | ||
|
|
1f06c9ac70 | ||
|
|
9188499965 | ||
|
|
e0567e8fa7 | ||
|
|
d253fe281a | ||
|
|
54baa44900 | ||
|
|
4ead0b3002 | ||
|
|
5b1c5f7b4e | ||
|
|
49a55d4763 | ||
|
|
5a414a3644 | ||
|
|
7532e5040b | ||
|
|
aaa823afd6 | ||
|
|
9d310ba57e | ||
|
|
c3f7aeca54 | ||
|
|
c752eea6a0 | ||
|
|
6255b93b78 | ||
|
|
56f1ccb8a8 | ||
|
|
8266099ae8 | ||
|
|
f9e2ce8649 | ||
|
|
5494ee7fc1 | ||
|
|
e56d687bb0 | ||
|
|
64d95768f8 | ||
|
|
56f8d1b8bc | ||
|
|
6377f89e85 | ||
|
|
570e7bd640 | ||
|
|
76b9ebbab4 | ||
|
|
006ff54fa8 | ||
|
|
9973e80a37 | ||
|
|
fb8c411f75 | ||
|
|
84f3b6475b | ||
|
|
d27836983a | ||
|
|
adf143edc5 | ||
|
|
bdec396180 | ||
|
|
8d3fcafcd9 | ||
|
|
606f59a102 | ||
|
|
d110b82347 | ||
|
|
3dbb96ea40 | ||
|
|
fa54b9b6ef | ||
|
|
3e2c3803ff | ||
|
|
0c4d9738c6 | ||
|
|
12493b3a0d | ||
|
|
aa91a70e46 | ||
|
|
e796b4cc61 | ||
|
|
b0b466618f | ||
|
|
770a8cebf4 | ||
|
|
e300ce36a4 | ||
|
|
4f52e401d2 | ||
|
|
98a6fff62d | ||
|
|
ca1db05c4e | ||
|
|
a265fd79e1 | ||
|
|
9df856339b | ||
|
|
d8ce11e86e | ||
|
|
806ebd75b6 | ||
|
|
ca324b7f52 | ||
|
|
41fa197212 | ||
|
|
a08edaca56 | ||
|
|
97d47c9c6c | ||
|
|
77b33f757c | ||
|
|
4c4727fa9f | ||
|
|
63ad9e4421 | ||
|
|
a93832626a | ||
|
|
9b81acd10c | ||
|
|
5c6d1aef5d | ||
|
|
df8905f7e4 | ||
|
|
738bbfa9a1 | ||
|
|
5ca8b7f4ba | ||
|
|
fdc08aeeb0 | ||
|
|
13b59ef85e | ||
|
|
a4ef8aa681 | ||
|
|
3308c92a71 | ||
|
|
37e0de3069 | ||
|
|
8ef1437bea | ||
|
|
1452f04cda | ||
|
|
7f6e97a37a | ||
|
|
ca391fbf0a | ||
|
|
80b723b510 | ||
|
|
c0b79a227d | ||
|
|
2a33a2ad88 | ||
|
|
5fe5a73cb1 | ||
|
|
93409c8f05 | ||
|
|
461fbaef1a | ||
|
|
dc84144d6b | ||
|
|
7ce05f468a | ||
|
|
7447d147d4 | ||
|
|
2222451712 | ||
|
|
cedd744e32 | ||
|
|
440fcb19ef | ||
|
|
a59330cf2c | ||
|
|
8a35b3a3dd | ||
|
|
fe803f8981 | ||
|
|
06e5f45904 | ||
|
|
667f0cc08e | ||
|
|
436a73dc9a | ||
|
|
5a1dd0c459 | ||
|
|
ce08448207 | ||
|
|
56c66f4883 | ||
|
|
82d0e4be73 | ||
|
|
f289ff3199 | ||
|
|
99ba61d605 | ||
|
|
116b0bc0c5 | ||
|
|
30ba1d890e | ||
|
|
0cc7938400 | ||
|
|
d2087aa168 | ||
|
|
675fd02afb | ||
|
|
2e5489eb56 | ||
|
|
c8778b9cfb | ||
|
|
ba9bf1f877 | ||
|
|
f43bd4ec28 | ||
|
|
30db3c5bbd | ||
|
|
ec9a1f1fda | ||
|
|
8a2738ca8a | ||
|
|
2ad3791d9d | ||
|
|
3103ab29bd | ||
|
|
0fc29ee7cf | ||
|
|
197dfb3485 | ||
|
|
bdecf75e0a | ||
|
|
c50e6cea3b | ||
|
|
7ad5cf4db5 | ||
|
|
938acedcc7 | ||
|
|
722a553501 | ||
|
|
d275aaa3f7 | ||
|
|
807beef418 | ||
|
|
6a1a22825b |
81 changed files with 4167 additions and 2155 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -4,7 +4,14 @@
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.tox
|
.tox
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
MANIFEST
|
MANIFEST
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
/tests/media
|
/tests/media/*
|
||||||
|
!/tests/media/reference.png
|
||||||
|
/venv
|
||||||
|
/venv3
|
||||||
|
/.env
|
||||||
|
/tags
|
||||||
|
|
|
||||||
34
.travis.yml
34
.travis.yml
|
|
@ -1,7 +1,35 @@
|
||||||
|
sudo: false
|
||||||
|
|
||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- 2.7
|
- "3.8"
|
||||||
install: pip install tox --use-mirrors
|
- "3.7"
|
||||||
script: tox -e py27-django13,py27-django12,py26-django13,py27-django12
|
- "3.6"
|
||||||
|
- "3.5"
|
||||||
|
env:
|
||||||
|
- DJANGO="master"
|
||||||
|
- DJANGO="30"
|
||||||
|
- DJANGO="22"
|
||||||
|
- DJANGO="21"
|
||||||
|
- DJANGO="21"
|
||||||
|
- DJANGO="20"
|
||||||
|
- DJANGO="111"
|
||||||
|
|
||||||
|
install:
|
||||||
|
- pip install tox
|
||||||
|
|
||||||
|
script:
|
||||||
|
- tox -e py$(python -c 'import sys;print("".join(map(str, sys.version_info[:2])))')-django${DJANGO}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fast_finish: true
|
||||||
|
allow_failures:
|
||||||
|
- env: DJANGO="master"
|
||||||
|
exclude:
|
||||||
|
- python: "3.5"
|
||||||
|
env: DJANGO="30"
|
||||||
|
- python: "3.5"
|
||||||
|
env: DJANGO="master"
|
||||||
|
|
||||||
notifications:
|
notifications:
|
||||||
irc: "irc.freenode.org#imagekit"
|
irc: "irc.freenode.org#imagekit"
|
||||||
|
|
|
||||||
15
AUTHORS
15
AUTHORS
|
|
@ -1,17 +1,18 @@
|
||||||
ImageKit was originally written by `Justin Driscoll`_.
|
ImageKit was originally written by `Justin Driscoll`_.
|
||||||
|
|
||||||
The field-based API was written by the bright minds at HZDG_.
|
The field-based API and other post-1.0 stuff was written by the bright people at
|
||||||
|
HZDG_.
|
||||||
|
|
||||||
Maintainers
|
Maintainers
|
||||||
~~~~~~~~~~~
|
-----------
|
||||||
|
|
||||||
* `Bryan Veloso`_
|
|
||||||
* `Matthew Tretter`_
|
* `Matthew Tretter`_
|
||||||
|
* `Bryan Veloso`_
|
||||||
* `Chris Drackett`_
|
* `Chris Drackett`_
|
||||||
* `Greg Newman`_
|
* `Greg Newman`_
|
||||||
|
|
||||||
Contributors
|
Contributors
|
||||||
~~~~~~~~~~~~
|
------------
|
||||||
|
|
||||||
* `Josh Ourisman`_
|
* `Josh Ourisman`_
|
||||||
* `Jonathan Slenders`_
|
* `Jonathan Slenders`_
|
||||||
|
|
@ -25,6 +26,9 @@ Contributors
|
||||||
* `Jan Sagemüller`_
|
* `Jan Sagemüller`_
|
||||||
* `Clay McClure`_
|
* `Clay McClure`_
|
||||||
* `Jannis Leidel`_
|
* `Jannis Leidel`_
|
||||||
|
* `Sean Bell`_
|
||||||
|
* `Saul Shanabrook`_
|
||||||
|
* `Venelin Stoykov`_
|
||||||
|
|
||||||
.. _Justin Driscoll: http://github.com/jdriscoll
|
.. _Justin Driscoll: http://github.com/jdriscoll
|
||||||
.. _HZDG: http://hzdg.com
|
.. _HZDG: http://hzdg.com
|
||||||
|
|
@ -44,3 +48,6 @@ Contributors
|
||||||
.. _Jan Sagemüller: https://github.com/version2
|
.. _Jan Sagemüller: https://github.com/version2
|
||||||
.. _Clay McClure: https://github.com/claymation
|
.. _Clay McClure: https://github.com/claymation
|
||||||
.. _Jannis Leidel: https://github.com/jezdez
|
.. _Jannis Leidel: https://github.com/jezdez
|
||||||
|
.. _Sean Bell: https://github.com/seanbell
|
||||||
|
.. _Saul Shanabrook: https://github.com/saulshanabrook
|
||||||
|
.. _Venelin Stoykov: https://github.com/vstoykov
|
||||||
|
|
|
||||||
24
CONTRIBUTING.rst
Normal file
24
CONTRIBUTING.rst
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
Contributing
|
||||||
|
------------
|
||||||
|
|
||||||
|
We love contributions! These guidelines will help make sure we can get your
|
||||||
|
contributions merged as quickly as possible:
|
||||||
|
|
||||||
|
1. Write `good commit messages`__!
|
||||||
|
2. If you want to add a new feature, talk to us on the `mailing list`__ or
|
||||||
|
`IRC`__ first. We might already have plans, or be able to offer some advice.
|
||||||
|
3. Make sure your code passes the tests that ImageKit already has. To run the
|
||||||
|
tests, first install tox, ``pip install tox``, then use ``tox``. This will let you know about any errors or style
|
||||||
|
issues.
|
||||||
|
4. While we're talking about tests, creating new ones for your code makes it
|
||||||
|
much easier for us to merge your code quickly. ImageKit uses nose_, so
|
||||||
|
writing tests is painless. Check out `ours`__ for examples.
|
||||||
|
5. It's a good idea to do your work in a branch; that way, you can work on more
|
||||||
|
than one contribution at a time without making them interdependent.
|
||||||
|
|
||||||
|
|
||||||
|
__ http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
|
||||||
|
__ https://groups.google.com/forum/#!forum/django-imagekit
|
||||||
|
__ irc://irc.freenode.net/imagekit
|
||||||
|
.. _nose: https://nose.readthedocs.org/en/latest/
|
||||||
|
__ https://github.com/matthewwithanm/django-imagekit/tree/develop/tests
|
||||||
17
MANIFEST.in
17
MANIFEST.in
|
|
@ -1,5 +1,18 @@
|
||||||
include AUTHORS
|
include AUTHORS
|
||||||
include LICENSE
|
include LICENSE
|
||||||
include README.rst
|
include README.rst
|
||||||
recursive-include docs *
|
include testrunner.py
|
||||||
recursive-include imagekit/templates *
|
include setup.cfg
|
||||||
|
include tests/*.py
|
||||||
|
include tests/assets/Lenna.png
|
||||||
|
include tests/assets/lenna-*.jpg
|
||||||
|
include tests/media/lenna.png
|
||||||
|
prune tests/media/CACHE
|
||||||
|
prune tests/media/b
|
||||||
|
prune tests/media/photos
|
||||||
|
include docs/Makefile
|
||||||
|
include docs/conf.py
|
||||||
|
include docs/make.bat
|
||||||
|
include docs/*.rst
|
||||||
|
recursive-include docs/_themes LICENSE README.rst flask_theme_support.py theme.conf *.css_t *.css *.html
|
||||||
|
recursive-include imagekit/templates *.html
|
||||||
|
|
|
||||||
478
README.rst
478
README.rst
|
|
@ -1,21 +1,30 @@
|
||||||
ImageKit is a Django app that helps you to add variations of uploaded images
|
|Build Status|_
|
||||||
to your models. These variations are called "specs" and can include things
|
|
||||||
like different sizes (e.g. thumbnails) and black and white versions.
|
.. |Build Status| image:: https://travis-ci.org/matthewwithanm/django-imagekit.svg?branch=develop
|
||||||
|
.. _Build Status: https://travis-ci.org/matthewwithanm/django-imagekit
|
||||||
|
|
||||||
|
ImageKit is a Django app for processing images. Need a thumbnail? A
|
||||||
|
black-and-white version of a user-uploaded image? ImageKit will make them for
|
||||||
|
you. If you need to programatically generate one image from another, you need
|
||||||
|
ImageKit.
|
||||||
|
|
||||||
|
ImageKit comes with a bunch of image processors for common tasks like resizing
|
||||||
|
and cropping, but you can also create your own. For an idea of what's possible,
|
||||||
|
check out the `Instakit`__ project.
|
||||||
|
|
||||||
**For the complete documentation on the latest stable version of ImageKit, see**
|
**For the complete documentation on the latest stable version of ImageKit, see**
|
||||||
`ImageKit on RTD`_. Our `changelog is also available`_.
|
`ImageKit on RTD`_.
|
||||||
|
|
||||||
.. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org
|
.. _`ImageKit on RTD`: http://django-imagekit.readthedocs.org
|
||||||
.. _`changelog is also available`: http://django-imagekit.readthedocs.org/en/latest/changelog.html
|
__ https://github.com/fish2000/instakit
|
||||||
|
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
------------
|
============
|
||||||
|
|
||||||
1. Install `PIL`_ or `Pillow`_. If you're using an ``ImageField`` in Django,
|
1. Install `PIL`_ or `Pillow`_. (If you're using an ``ImageField`` in Django,
|
||||||
you should have already done this.
|
you should have already done this.)
|
||||||
2. ``pip install django-imagekit``
|
2. ``pip install django-imagekit``
|
||||||
(or clone the source and put the imagekit module on your path)
|
|
||||||
3. Add ``'imagekit'`` to your ``INSTALLED_APPS`` list in your project's settings.py
|
3. Add ``'imagekit'`` to your ``INSTALLED_APPS`` list in your project's settings.py
|
||||||
|
|
||||||
.. note:: If you've never seen Pillow before, it considers itself a
|
.. note:: If you've never seen Pillow before, it considers itself a
|
||||||
|
|
@ -27,85 +36,324 @@ Installation
|
||||||
.. _`Pillow`: http://pypi.python.org/pypi/Pillow
|
.. _`Pillow`: http://pypi.python.org/pypi/Pillow
|
||||||
|
|
||||||
|
|
||||||
Adding Specs to a Model
|
Usage Overview
|
||||||
-----------------------
|
==============
|
||||||
|
|
||||||
Much like ``django.db.models.ImageField``, Specs are defined as properties
|
.. _specs:
|
||||||
of a model class:
|
|
||||||
|
Specs
|
||||||
|
-----
|
||||||
|
|
||||||
|
You have one image and you want to do something to it to create another image.
|
||||||
|
But how do you tell ImageKit what to do? By defining an image spec.
|
||||||
|
|
||||||
|
An **image spec** is a type of **image generator** that generates a new image
|
||||||
|
from a source image.
|
||||||
|
|
||||||
|
|
||||||
|
Defining Specs In Models
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
The easiest way to use define an image spec is by using an ImageSpecField on
|
||||||
|
your model class:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
class Photo(models.Model):
|
class Profile(models.Model):
|
||||||
original_image = models.ImageField(upload_to='photos')
|
avatar = models.ImageField(upload_to='avatars')
|
||||||
formatted_image = ImageSpecField(image_field='original_image', format='JPEG',
|
avatar_thumbnail = ImageSpecField(source='avatar',
|
||||||
options={'quality': 90})
|
processors=[ResizeToFill(100, 50)],
|
||||||
|
format='JPEG',
|
||||||
|
options={'quality': 60})
|
||||||
|
|
||||||
Accessing the spec through a model instance will create the image and return
|
profile = Profile.objects.all()[0]
|
||||||
an ImageFile-like object (just like with a normal
|
print(profile.avatar_thumbnail.url) # > /media/CACHE/images/982d5af84cddddfd0fbf70892b4431e4.jpg
|
||||||
``django.db.models.ImageField``):
|
print(profile.avatar_thumbnail.width) # > 100
|
||||||
|
|
||||||
.. code-block:: python
|
As you can probably tell, ImageSpecFields work a lot like Django's
|
||||||
|
ImageFields. The difference is that they're automatically generated by
|
||||||
|
ImageKit based on the instructions you give. In the example above, the avatar
|
||||||
|
thumbnail is a resized version of the avatar image, saved as a JPEG with a
|
||||||
|
quality of 60.
|
||||||
|
|
||||||
photo = Photo.objects.all()[0]
|
Sometimes, however, you don't need to keep the original image (the avatar in
|
||||||
photo.original_image.url # > '/media/photos/birthday.tiff'
|
the above example); when the user uploads an image, you just want to process it
|
||||||
photo.formatted_image.url # > '/media/cache/photos/birthday_formatted_image.jpeg'
|
and save the result. In those cases, you can use the ``ProcessedImageField``
|
||||||
|
class:
|
||||||
Check out ``imagekit.models.ImageSpecField`` for more information.
|
|
||||||
|
|
||||||
If you only want to save the processed image (without maintaining the original),
|
|
||||||
you can use a ``ProcessedImageField``:
|
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from imagekit.models.fields import ProcessedImageField
|
from imagekit.models import ProcessedImageField
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
class Photo(models.Model):
|
class Profile(models.Model):
|
||||||
processed_image = ProcessedImageField(format='JPEG', options={'quality': 90})
|
avatar_thumbnail = ProcessedImageField(upload_to='avatars',
|
||||||
|
processors=[ResizeToFill(100, 50)],
|
||||||
|
format='JPEG',
|
||||||
|
options={'quality': 60})
|
||||||
|
|
||||||
See the class documentation for details.
|
profile = Profile.objects.all()[0]
|
||||||
|
print(profile.avatar_thumbnail.url) # > /media/avatars/MY-avatar.jpg
|
||||||
|
print(profile.avatar_thumbnail.width) # > 100
|
||||||
|
|
||||||
|
This is pretty similar to our previous example. We don't need to specify a
|
||||||
|
"source" any more since we're not processing another image field, but we do need
|
||||||
|
to pass an "upload_to" argument. This behaves exactly as it does for Django
|
||||||
|
ImageFields.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
You might be wondering why we didn't need an "upload_to" argument for our
|
||||||
|
ImageSpecField. The reason is that ProcessedImageFields really are just like
|
||||||
|
ImageFields—they save the file path in the database and you need to run
|
||||||
|
syncdb (or create a migration) when you add one to your model.
|
||||||
|
|
||||||
|
ImageSpecFields, on the other hand, are virtual—they add no fields to your
|
||||||
|
database and don't require a database. This is handy for a lot of reasons,
|
||||||
|
but it means that the path to the image file needs to be programmatically
|
||||||
|
constructed based on the source image and the spec.
|
||||||
|
|
||||||
|
|
||||||
|
Defining Specs Outside of Models
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Defining specs as models fields is one very convenient way to process images,
|
||||||
|
but it isn't the only way. Sometimes you can't (or don't want to) add fields to
|
||||||
|
your models, and that's okay. You can define image spec classes and use them
|
||||||
|
directly. This can be especially useful for doing image processing in views—
|
||||||
|
particularly when the processing being done depends on user input.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from imagekit import ImageSpec
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
|
class Thumbnail(ImageSpec):
|
||||||
|
processors = [ResizeToFill(100, 50)]
|
||||||
|
format = 'JPEG'
|
||||||
|
options = {'quality': 60}
|
||||||
|
|
||||||
|
It's probably not surprising that this class is capable of processing an image
|
||||||
|
in the exact same way as our ImageSpecField above. However, unlike with the
|
||||||
|
image spec model field, this class doesn't define what source the spec is acting
|
||||||
|
on, or what should be done with the result; that's up to you:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
source_file = open('/path/to/myimage.jpg', 'rb')
|
||||||
|
image_generator = Thumbnail(source=source_file)
|
||||||
|
result = image_generator.generate()
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
You don't have to use ``open``! You can use whatever File-like object you
|
||||||
|
want—including a model's ``ImageField``.
|
||||||
|
|
||||||
|
The result of calling ``generate()`` on an image spec is a file-like object
|
||||||
|
containing our resized image, with which you can do whatever you want. For
|
||||||
|
example, if you wanted to save it to disk:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
dest = open('/path/to/dest.jpg', 'wb')
|
||||||
|
dest.write(result.read())
|
||||||
|
dest.close()
|
||||||
|
|
||||||
|
|
||||||
|
Using Specs In Templates
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
If you have a model with an ImageSpecField or ProcessedImageField, you can
|
||||||
|
easily use those processed image just as you would a normal image field:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<img src="{{ profile.avatar_thumbnail.url }}" />
|
||||||
|
|
||||||
|
(This is assuming you have a view that's setting a context variable named
|
||||||
|
"profile" to an instance of our Profile model.)
|
||||||
|
|
||||||
|
But you can also generate processed image files directly in your template—from
|
||||||
|
any image—without adding anything to your model. In order to do this, you'll
|
||||||
|
first have to define an image generator class (remember, specs are a type of
|
||||||
|
generator) in your app somewhere, just as we did in the last section. You'll
|
||||||
|
also need a way of referring to the generator in your template, so you'll need
|
||||||
|
to register it.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from imagekit import ImageSpec, register
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
|
class Thumbnail(ImageSpec):
|
||||||
|
processors = [ResizeToFill(100, 50)]
|
||||||
|
format = 'JPEG'
|
||||||
|
options = {'quality': 60}
|
||||||
|
|
||||||
|
register.generator('myapp:thumbnail', Thumbnail)
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
You can register your generator with any id you want, but choose wisely!
|
||||||
|
If you pick something too generic, you could have a conflict with another
|
||||||
|
third-party app you're using. For this reason, it's a good idea to prefix
|
||||||
|
your generator ids with the name of your app. Also, ImageKit recognizes
|
||||||
|
colons as separators when doing pattern matching (e.g. in the generateimages
|
||||||
|
management command), so it's a good idea to use those too!
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
This code can go in any file you want—but you need to make sure it's loaded!
|
||||||
|
In order to keep things simple, ImageKit will automatically try to load an
|
||||||
|
module named "imagegenerators" in each of your installed apps. So why don't
|
||||||
|
you just save yourself the headache and put your image specs in there?
|
||||||
|
|
||||||
|
Now that we've created an image generator class and registered it with ImageKit,
|
||||||
|
we can use it in our templates!
|
||||||
|
|
||||||
|
|
||||||
|
generateimage
|
||||||
|
"""""""""""""
|
||||||
|
|
||||||
|
The most generic template tag that ImageKit gives you is called "generateimage".
|
||||||
|
It requires at least one argument: the id of a registered image generator.
|
||||||
|
Additional keyword-style arguments are passed to the registered generator class.
|
||||||
|
As we saw above, image spec constructors expect a source keyword argument, so
|
||||||
|
that's what we need to pass to use our thumbnail spec:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
{% load imagekit %}
|
||||||
|
|
||||||
|
{% generateimage 'myapp:thumbnail' source=source_file %}
|
||||||
|
|
||||||
|
This will output the following HTML:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<img src="/media/CACHE/images/982d5af84cddddfd0fbf70892b4431e4.jpg" width="100" height="50" />
|
||||||
|
|
||||||
|
You can also add additional HTML attributes; just separate them from your
|
||||||
|
keyword args using two dashes:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
{% load imagekit %}
|
||||||
|
|
||||||
|
{% generateimage 'myapp:thumbnail' source=source_file -- alt="A picture of Me" id="mypicture" %}
|
||||||
|
|
||||||
|
Not generating HTML image tags? No problem. The tag also functions as an
|
||||||
|
assignment tag, providing access to the underlying file object:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
{% load imagekit %}
|
||||||
|
|
||||||
|
{% generateimage 'myapp:thumbnail' source=source_file as th %}
|
||||||
|
<a href="{{ th.url }}">Click to download a cool {{ th.width }} x {{ th.height }} image!</a>
|
||||||
|
|
||||||
|
|
||||||
|
thumbnail
|
||||||
|
"""""""""
|
||||||
|
|
||||||
|
Because it's such a common use case, ImageKit also provides a "thumbnail"
|
||||||
|
template tag:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
{% load imagekit %}
|
||||||
|
|
||||||
|
{% thumbnail '100x50' source_file %}
|
||||||
|
|
||||||
|
Like the generateimage tag, the thumbnail tag outputs an <img> tag:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
<img src="/media/CACHE/images/982d5af84cddddfd0fbf70892b4431e4.jpg" width="100" height="50" />
|
||||||
|
|
||||||
|
Comparing this syntax to the generateimage tag above, you'll notice a few
|
||||||
|
differences.
|
||||||
|
|
||||||
|
First, we didn't have to specify an image generator id; unless we tell it
|
||||||
|
otherwise, thumbnail tag uses the generator registered with the id
|
||||||
|
"imagekit:thumbnail". **It's important to note that this tag is *not* using the
|
||||||
|
Thumbnail spec class we defined earlier**; it's using the generator registered
|
||||||
|
with the id "imagekit:thumbnail" which, by default, is
|
||||||
|
``imagekit.generatorlibrary.Thumbnail``.
|
||||||
|
|
||||||
|
Second, we're passing two positional arguments (the dimensions and the source
|
||||||
|
image) as opposed to the keyword arguments we used with the generateimage tag.
|
||||||
|
|
||||||
|
Like with the generateimage tag, you can also specify additional HTML attributes
|
||||||
|
for the thumbnail tag, or use it as an assignment tag:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
{% load imagekit %}
|
||||||
|
|
||||||
|
{% thumbnail '100x50' source_file -- alt="A picture of Me" id="mypicture" %}
|
||||||
|
{% thumbnail '100x50' source_file as th %}
|
||||||
|
|
||||||
|
|
||||||
|
Using Specs in Forms
|
||||||
|
^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
In addition to the model field above, there's also a form field version of the
|
||||||
|
``ProcessedImageField`` class. The functionality is basically the same (it
|
||||||
|
processes an image once and saves the result), but it's used in a form class:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from imagekit.forms import ProcessedImageField
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
|
class ProfileForm(forms.Form):
|
||||||
|
avatar_thumbnail = ProcessedImageField(spec_id='myapp:profile:avatar_thumbnail',
|
||||||
|
processors=[ResizeToFill(100, 50)],
|
||||||
|
format='JPEG',
|
||||||
|
options={'quality': 60})
|
||||||
|
|
||||||
|
The benefit of using ``imagekit.forms.ProcessedImageField`` (as opposed to
|
||||||
|
``imagekit.models.ProcessedImageField`` above) is that it keeps the logic for
|
||||||
|
creating the image outside of your model (in which you would use a normal Django
|
||||||
|
ImageField). You can even create multiple forms, each with their own
|
||||||
|
ProcessedImageField, that all store their results in the same image field.
|
||||||
|
|
||||||
|
|
||||||
Processors
|
Processors
|
||||||
----------
|
----------
|
||||||
|
|
||||||
The real power of ImageKit comes from processors. Processors take an image, do
|
So far, we've only seen one processor: ``imagekit.processors.ResizeToFill``. But
|
||||||
something to it, and return the result. By providing a list of processors to
|
ImageKit is capable of far more than just resizing images, and that power comes
|
||||||
your spec, you can expose different versions of the original image:
|
from its processors.
|
||||||
|
|
||||||
|
Processors take a PIL image object, do something to it, and return a new one.
|
||||||
|
A spec can make use of as many processors as you'd like, which will all be run
|
||||||
|
in order.
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
from django.db import models
|
from imagekit import ImageSpec
|
||||||
from imagekit.models import ImageSpecField
|
from imagekit.processors import TrimBorderColor, Adjust
|
||||||
from imagekit.processors import ResizeToFill, Adjust
|
|
||||||
|
|
||||||
class Photo(models.Model):
|
class MySpec(ImageSpec):
|
||||||
original_image = models.ImageField(upload_to='photos')
|
processors = [
|
||||||
thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
|
TrimBorderColor(),
|
||||||
ResizeToFill(50, 50)], image_field='original_image',
|
Adjust(contrast=1.2, sharpness=1.1),
|
||||||
format='JPEG', options={'quality': 90})
|
]
|
||||||
|
format = 'JPEG'
|
||||||
The ``thumbnail`` property will now return a cropped image:
|
options = {'quality': 60}
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
photo = Photo.objects.all()[0]
|
|
||||||
photo.thumbnail.url # > '/media/cache/photos/birthday_thumbnail.jpeg'
|
|
||||||
photo.thumbnail.width # > 50
|
|
||||||
photo.original_image.width # > 1000
|
|
||||||
|
|
||||||
The original image is not modified; ``thumbnail`` is a new file that is the
|
|
||||||
result of running the ``imagekit.processors.ResizeToFill`` processor on the
|
|
||||||
original. (If you only need to save the processed image, and not the original,
|
|
||||||
pass processors to a ``ProcessedImageField`` instead of an ``ImageSpecField``.)
|
|
||||||
|
|
||||||
The ``imagekit.processors`` module contains processors for many common
|
The ``imagekit.processors`` module contains processors for many common
|
||||||
image manipulations, like resizing, rotating, and color adjustments. However,
|
image manipulations, like resizing, rotating, and color adjustments. However,
|
||||||
if they aren't up to the task, you can create your own. All you have to do is
|
if they aren't up to the task, you can create your own. All you have to do is
|
||||||
implement a ``process()`` method:
|
define a class that implements a ``process()`` method:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
|
|
@ -114,10 +362,29 @@ implement a ``process()`` method:
|
||||||
# Code for adding the watermark goes here.
|
# Code for adding the watermark goes here.
|
||||||
return image
|
return image
|
||||||
|
|
||||||
class Photo(models.Model):
|
That's all there is to it! To use your fancy new custom processor, just include
|
||||||
original_image = models.ImageField(upload_to='photos')
|
it in your spec's ``processors`` list:
|
||||||
watermarked_image = ImageSpecField([Watermark()], image_field='original_image',
|
|
||||||
format='JPEG', options={'quality': 90})
|
.. code-block:: python
|
||||||
|
|
||||||
|
from imagekit import ImageSpec
|
||||||
|
from imagekit.processors import TrimBorderColor, Adjust
|
||||||
|
from myapp.processors import Watermark
|
||||||
|
|
||||||
|
class MySpec(ImageSpec):
|
||||||
|
processors = [
|
||||||
|
TrimBorderColor(),
|
||||||
|
Adjust(contrast=1.2, sharpness=1.1),
|
||||||
|
Watermark(),
|
||||||
|
]
|
||||||
|
format = 'JPEG'
|
||||||
|
options = {'quality': 60}
|
||||||
|
|
||||||
|
Note that when you import a processor from ``imagekit.processors``, imagekit
|
||||||
|
in turn imports the processor from `PILKit`_. So if you are looking for
|
||||||
|
available processors, look at PILKit.
|
||||||
|
|
||||||
|
.. _`PILKit`: https://github.com/matthewwithanm/pilkit
|
||||||
|
|
||||||
|
|
||||||
Admin
|
Admin
|
||||||
|
|
@ -134,65 +401,68 @@ Django admin classes:
|
||||||
from imagekit.admin import AdminThumbnail
|
from imagekit.admin import AdminThumbnail
|
||||||
from .models import Photo
|
from .models import Photo
|
||||||
|
|
||||||
|
|
||||||
class PhotoAdmin(admin.ModelAdmin):
|
class PhotoAdmin(admin.ModelAdmin):
|
||||||
list_display = ('__str__', 'admin_thumbnail')
|
list_display = ('__str__', 'admin_thumbnail')
|
||||||
admin_thumbnail = AdminThumbnail(image_field='thumbnail')
|
admin_thumbnail = AdminThumbnail(image_field='thumbnail')
|
||||||
|
|
||||||
|
admin.site.register(Photo, PhotoAdmin)
|
||||||
|
|
||||||
|
To use specs defined outside of models:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from imagekit.admin import AdminThumbnail
|
||||||
|
from imagekit import ImageSpec
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
from imagekit.cachefiles import ImageCacheFile
|
||||||
|
|
||||||
|
from .models import Photo
|
||||||
|
|
||||||
|
class AdminThumbnailSpec(ImageSpec):
|
||||||
|
processors = [ResizeToFill(100, 30)]
|
||||||
|
format = 'JPEG'
|
||||||
|
options = {'quality': 60 }
|
||||||
|
|
||||||
|
def cached_admin_thumb(instance):
|
||||||
|
# `image` is the name of the image field on the model
|
||||||
|
cached = ImageCacheFile(AdminThumbnailSpec(instance.image))
|
||||||
|
# only generates the first time, subsequent calls use cache
|
||||||
|
cached.generate()
|
||||||
|
return cached
|
||||||
|
|
||||||
|
class PhotoAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('__str__', 'admin_thumbnail')
|
||||||
|
admin_thumbnail = AdminThumbnail(image_field=cached_admin_thumb)
|
||||||
|
|
||||||
admin.site.register(Photo, PhotoAdmin)
|
admin.site.register(Photo, PhotoAdmin)
|
||||||
|
|
||||||
|
|
||||||
AdminThumbnail can even use a custom template. For more information, see
|
AdminThumbnail can even use a custom template. For more information, see
|
||||||
``imagekit.admin.AdminThumbnail``.
|
``imagekit.admin.AdminThumbnail``.
|
||||||
|
|
||||||
.. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list
|
.. _`Django admin change list`: https://docs.djangoproject.com/en/dev/intro/tutorial02/#customize-the-admin-change-list
|
||||||
|
|
||||||
|
|
||||||
Image Cache Backends
|
Management Commands
|
||||||
--------------------
|
-------------------
|
||||||
|
|
||||||
Whenever you access properties like ``url``, ``width`` and ``height`` of an
|
ImageKit has one management command—`generateimages`—which will generate cache
|
||||||
``ImageSpecField``, its cached image is validated; whenever you save a new image
|
files for all of your registered image generators. You can also pass it a list
|
||||||
to the ``ImageField`` your spec uses as a source, the spec image is invalidated.
|
of generator ids in order to generate images selectively.
|
||||||
The default way to validate a cache image is to check to see if the file exists
|
|
||||||
and, if not, generate a new one; the default way to invalidate the cache is to
|
|
||||||
delete the image. This is a very simple and straightforward way to handle cache
|
|
||||||
validation, but it has its drawbacks—for example, checking to see if the image
|
|
||||||
exists means frequently hitting the storage backend.
|
|
||||||
|
|
||||||
Because of this, ImageKit allows you to define custom image cache backends. To
|
|
||||||
be a valid image cache backend, a class must implement three methods:
|
|
||||||
``validate``, ``invalidate``, and ``clear`` (which is called when the image is
|
|
||||||
no longer needed in any form, i.e. the model is deleted). Each of these methods
|
|
||||||
must accept a file object, but the internals are up to you. For example, you
|
|
||||||
could store the state (valid, invalid) of the cache in a database to avoid
|
|
||||||
filesystem access. You can then specify your image cache backend on a per-field
|
|
||||||
basis:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
class Photo(models.Model):
|
|
||||||
...
|
|
||||||
thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend())
|
|
||||||
|
|
||||||
Or in your ``settings.py`` file if you want to use it as the default:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend'
|
|
||||||
|
|
||||||
|
|
||||||
Community
|
Community
|
||||||
---------
|
=========
|
||||||
|
|
||||||
Please use `the GitHub issue tracker <https://github.com/jdriscoll/django-imagekit/issues>`_
|
Please use `the GitHub issue tracker <https://github.com/matthewwithanm/django-imagekit/issues>`_
|
||||||
to report bugs with django-imagekit. `A mailing list <https://groups.google.com/forum/#!forum/django-imagekit>`_
|
to report bugs with django-imagekit. `A mailing list <https://groups.google.com/forum/#!forum/django-imagekit>`_
|
||||||
also exists to discuss the project and ask questions, as well as the official
|
also exists to discuss the project and ask questions, as well as the official
|
||||||
`#imagekit <irc://irc.freenode.net/imagekit>`_ channel on Freenode.
|
`#imagekit <irc://irc.freenode.net/imagekit>`_ channel on Freenode.
|
||||||
|
|
||||||
|
|
||||||
Contributing
|
Contributing
|
||||||
------------
|
============
|
||||||
|
|
||||||
We love contributions! And you don't have to be an expert with the library—or
|
We love contributions! And you don't have to be an expert with the library—or
|
||||||
even Django—to contribute either: ImageKit's processors are standalone classes
|
even Django—to contribute either: ImageKit's processors are standalone classes
|
||||||
|
|
@ -200,6 +470,12 @@ that are completely separate from the more intimidating internals of Django's
|
||||||
ORM. If you've written a processor that you think might be useful to other
|
ORM. If you've written a processor that you think might be useful to other
|
||||||
people, open a pull request so we can take a look!
|
people, open a pull request so we can take a look!
|
||||||
|
|
||||||
ImageKit's image cache backends are also fairly isolated from the ImageKit guts.
|
You can also check out our list of `open, contributor-friendly issues`__ for
|
||||||
If you've fine-tuned one to work perfectly for a popular file storage backend,
|
ideas.
|
||||||
let us take a look! Maybe other people could use it.
|
|
||||||
|
Check out our `contributing guidelines`__ for more information about pitching in
|
||||||
|
with ImageKit.
|
||||||
|
|
||||||
|
|
||||||
|
__ https://github.com/matthewwithanm/django-imagekit/issues?labels=contributor-friendly&state=open
|
||||||
|
__ https://github.com/matthewwithanm/django-imagekit/blob/develop/CONTRIBUTING.rst
|
||||||
|
|
|
||||||
182
docs/advanced_usage.rst
Normal file
182
docs/advanced_usage.rst
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
Advanced Usage
|
||||||
|
**************
|
||||||
|
|
||||||
|
|
||||||
|
Models
|
||||||
|
======
|
||||||
|
|
||||||
|
|
||||||
|
The ``ImageSpecField`` Shorthand Syntax
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
If you've read the README, you already know what an ``ImageSpecField`` is and
|
||||||
|
the basics of defining one:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
avatar = models.ImageField(upload_to='avatars')
|
||||||
|
avatar_thumbnail = ImageSpecField(source='avatar',
|
||||||
|
processors=[ResizeToFill(100, 50)],
|
||||||
|
format='JPEG',
|
||||||
|
options={'quality': 60})
|
||||||
|
|
||||||
|
This will create an ``avatar_thumbnail`` field which is a resized version of the
|
||||||
|
image stored in the ``avatar`` image field. But this is actually just shorthand
|
||||||
|
for creating an ``ImageSpec``, registering it, and associating it with an
|
||||||
|
``ImageSpecField``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from imagekit import ImageSpec, register
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
|
class AvatarThumbnail(ImageSpec):
|
||||||
|
processors = [ResizeToFill(100, 50)]
|
||||||
|
format = 'JPEG'
|
||||||
|
options = {'quality': 60}
|
||||||
|
|
||||||
|
register.generator('myapp:profile:avatar_thumbnail', AvatarThumbnail)
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
avatar = models.ImageField(upload_to='avatars')
|
||||||
|
avatar_thumbnail = ImageSpecField(source='avatar',
|
||||||
|
id='myapp:profile:avatar_thumbnail')
|
||||||
|
|
||||||
|
Obviously, the shorthand version is a lot, well…shorter. So why would you ever
|
||||||
|
want to go through the trouble of using the long form? The answer is that the
|
||||||
|
long form—creating an image spec class and registering it—gives you a lot more
|
||||||
|
power over the generated image.
|
||||||
|
|
||||||
|
.. _dynamic-specs:
|
||||||
|
|
||||||
|
Specs That Change
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
As you'll remember from the README, an image spec is just a type of image
|
||||||
|
generator that generates a new image from a source image. How does the image
|
||||||
|
spec get access to the source image? Simple! It's passed to the constructor as
|
||||||
|
a keyword argument and stored as an attribute of the spec. Normally, we don't
|
||||||
|
have to concern ourselves with this; the ``ImageSpec`` knows what to do with the
|
||||||
|
source image and we're happy to let it do its thing. However, having access to
|
||||||
|
the source image in our spec class can be very useful…
|
||||||
|
|
||||||
|
Often, when using an ``ImageSpecField``, you may want the spec to vary based on
|
||||||
|
properties of a model. (For example, you might want to store image dimensions on
|
||||||
|
the model and then use them to generate your thumbnail.) Now that we know how to
|
||||||
|
access the source image from our spec, it's a simple matter to extract its model
|
||||||
|
and use it to create our processors list. In fact, ImageKit includes a utility
|
||||||
|
for getting this information.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
:emphasize-lines: 11-14
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from imagekit import ImageSpec, register
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
from imagekit.utils import get_field_info
|
||||||
|
|
||||||
|
class AvatarThumbnail(ImageSpec):
|
||||||
|
format = 'JPEG'
|
||||||
|
options = {'quality': 60}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def processors(self):
|
||||||
|
model, field_name = get_field_info(self.source)
|
||||||
|
return [ResizeToFill(model.thumbnail_width, thumbnail.avatar_height)]
|
||||||
|
|
||||||
|
register.generator('myapp:profile:avatar_thumbnail', AvatarThumbnail)
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
avatar = models.ImageField(upload_to='avatars')
|
||||||
|
avatar_thumbnail = ImageSpecField(source='avatar',
|
||||||
|
id='myapp:profile:avatar_thumbnail')
|
||||||
|
thumbnail_width = models.PositiveIntegerField()
|
||||||
|
thumbnail_height = models.PositiveIntegerField()
|
||||||
|
|
||||||
|
Now each avatar thumbnail will be resized according to the dimensions stored on
|
||||||
|
the model!
|
||||||
|
|
||||||
|
Of course, processors aren't the only thing that can vary based on the model of
|
||||||
|
the source image; spec behavior can change in any way you want.
|
||||||
|
|
||||||
|
|
||||||
|
.. _source-groups:
|
||||||
|
|
||||||
|
Source Groups
|
||||||
|
=============
|
||||||
|
|
||||||
|
When you run the ``generateimages`` management command, how does ImageKit know
|
||||||
|
which source images to use with which specs? Obviously, when you define an
|
||||||
|
ImageSpecField, the source image is being connected to a spec, but what's going
|
||||||
|
on underneath the hood?
|
||||||
|
|
||||||
|
The answer is that, when you define an ImageSpecField, ImageKit automatically
|
||||||
|
creates and registers an object called a *source group*. Source groups are
|
||||||
|
responsible for two things:
|
||||||
|
|
||||||
|
1. They dispatch signals when a source is saved, and
|
||||||
|
2. They expose a generator method that enumerates source files.
|
||||||
|
|
||||||
|
When these objects are registered (using ``imagekit.register.source_group()``),
|
||||||
|
their signals will trigger callbacks on the cache file strategies associated
|
||||||
|
with image specs that use the source. (So, for example, you can chose to
|
||||||
|
generate a file every time the source image changes.) In addition, the generator
|
||||||
|
method is used (indirectly) to create the list of files to generate with the
|
||||||
|
``generateimages`` management command.
|
||||||
|
|
||||||
|
Currently, there is only one source group class bundled with ImageKit—the one
|
||||||
|
used by ImageSpecFields. This source group
|
||||||
|
(``imagekit.specs.sourcegroups.ImageFieldSourceGroup``) represents an ImageField
|
||||||
|
on every instance of a particular model. In terms of the above description, the
|
||||||
|
instance ``ImageFieldSourceGroup(Profile, 'avatar')`` 1) dispatches a signal
|
||||||
|
every time the image in Profile's avatar ImageField changes, and 2) exposes a
|
||||||
|
generator method that iterates over every Profile's "avatar" image.
|
||||||
|
|
||||||
|
Chances are, this is the only source group you will ever need to use, however,
|
||||||
|
ImageKit lets you define and register custom source groups easily. This may be
|
||||||
|
useful, for example, if you're using the template tags "generateimage" and
|
||||||
|
"thumbnail" and the optimistic cache file strategy. Again, the purpose is
|
||||||
|
to tell ImageKit which specs are used with which sources (so the
|
||||||
|
"generateimages" management command can generate those files) and when the
|
||||||
|
source image has been created or changed (so that the strategy has the
|
||||||
|
opportunity to act on it).
|
||||||
|
|
||||||
|
A simple example of a custom source group class is as follows:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
class JpegsInADirectory(object):
|
||||||
|
def __init__(self, dir):
|
||||||
|
self.dir = dir
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
os.chdir(self.dir)
|
||||||
|
for name in glob.glob('*.jpg'):
|
||||||
|
yield open(name, 'rb')
|
||||||
|
|
||||||
|
Instances of this class could then be registered with one or more spec id:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from imagekit import register
|
||||||
|
|
||||||
|
register.source_group('myapp:profile:avatar_thumbnail', JpegsInADirectory('/path/to/some/pics'))
|
||||||
|
|
||||||
|
Running the "generateimages" management command would now cause thumbnails to be
|
||||||
|
generated (using the "myapp:profile:avatar_thumbnail" spec) for each of the
|
||||||
|
JPEGs in `/path/to/some/pics`.
|
||||||
|
|
||||||
|
Note that, since this source group doesnt send the `source_saved` signal, the
|
||||||
|
corresponding cache file strategy callbacks would not be called for them.
|
||||||
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
API Reference
|
|
||||||
=============
|
|
||||||
|
|
||||||
|
|
||||||
:mod:`models` Module
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
.. automodule:: imagekit.models.fields
|
|
||||||
:members:
|
|
||||||
|
|
||||||
|
|
||||||
:mod:`processors` Module
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
.. automodule:: imagekit.processors
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: imagekit.processors.resize
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: imagekit.processors.crop
|
|
||||||
:members:
|
|
||||||
|
|
||||||
|
|
||||||
:mod:`admin` Module
|
|
||||||
--------------------
|
|
||||||
|
|
||||||
.. automodule:: imagekit.admin
|
|
||||||
:members:
|
|
||||||
256
docs/caching.rst
Normal file
256
docs/caching.rst
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
Caching
|
||||||
|
*******
|
||||||
|
|
||||||
|
|
||||||
|
Default Backend Workflow
|
||||||
|
========================
|
||||||
|
|
||||||
|
|
||||||
|
``ImageSpec``
|
||||||
|
-------------
|
||||||
|
|
||||||
|
At the heart of ImageKit are image generators. These are classes with a
|
||||||
|
``generate()`` method which returns an image file. An image spec is a type of
|
||||||
|
image generator. The thing that makes specs special is that they accept a source
|
||||||
|
image. So an image spec is just an image generator that makes an image from some
|
||||||
|
other image.
|
||||||
|
|
||||||
|
|
||||||
|
``ImageCacheFile``
|
||||||
|
------------------
|
||||||
|
|
||||||
|
However, an image spec by itself would be vastly inefficient. Every time an
|
||||||
|
an image was accessed in some way, it would have be regenerated and saved.
|
||||||
|
Most of the time, you want to re-use a previously generated image, based on the
|
||||||
|
input image and spec, instead of generating a new one. That's where
|
||||||
|
``ImageCacheFile`` comes in. ``ImageCacheFile`` is a File-like object that
|
||||||
|
wraps an image generator. They look and feel just like regular file
|
||||||
|
objects, but they've got a little trick up their sleeve: they represent files
|
||||||
|
that may not actually exist!
|
||||||
|
|
||||||
|
|
||||||
|
.. _cache-file-strategy:
|
||||||
|
|
||||||
|
Cache File Strategy
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Each ``ImageCacheFile`` has a cache file strategy, which abstracts away when
|
||||||
|
image is actually generated. It can implement the following three methods:
|
||||||
|
|
||||||
|
* ``on_content_required`` - called by ``ImageCacheFile`` when it requires the
|
||||||
|
contents of the generated image. For example, when you call ``read()`` or
|
||||||
|
try to access information contained in the file.
|
||||||
|
* ``on_existence_required`` - called by ``ImageCacheFile`` when it requires the
|
||||||
|
generated image to exist but may not be concerned with its contents. For
|
||||||
|
example, when you access its ``url`` or ``path`` attribute.
|
||||||
|
* ``on_source_saved`` - called when the source of a spec is saved
|
||||||
|
|
||||||
|
The default strategy only defines the first two of these, as follows:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class JustInTime(object):
|
||||||
|
def on_content_required(self, file):
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
def on_existence_required(self, file):
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
|
||||||
|
.. _cache-file-backend:
|
||||||
|
|
||||||
|
Cache File Backend
|
||||||
|
------------------
|
||||||
|
|
||||||
|
The ``generate`` method on the ``ImageCacheFile`` is further delegated to the
|
||||||
|
cache file backend, which abstracts away how an image is generated.
|
||||||
|
|
||||||
|
The cache file backend defaults to the setting
|
||||||
|
``IMAGEKIT_DEFAULT_CACHEFILE_BACKEND`` and can be set explicitly on a spec with
|
||||||
|
the ``cachefile_backend`` attribute.
|
||||||
|
|
||||||
|
The default works like this:
|
||||||
|
|
||||||
|
* Checks the file storage to see if a file exists
|
||||||
|
* If not, caches that information for 5 seconds
|
||||||
|
* If it does, caches that information in the ``IMAGEKIT_CACHE_BACKEND``
|
||||||
|
|
||||||
|
If file doesn't exist, generates it immediately and synchronously
|
||||||
|
|
||||||
|
|
||||||
|
That pretty much covers the architecture of the caching layer, and its default
|
||||||
|
behavior. I like the default behavior. When will an image be regenerated?
|
||||||
|
Whenever it needs to be! When will your storage backend get hit? Depending on
|
||||||
|
your ``IMAGEKIT_CACHE_BACKEND`` settings, as little as twice per file (once for the
|
||||||
|
existence check and once to save the generated file). What if you want to change
|
||||||
|
a spec? The generated file name (which is used as part of the cache keys) vary
|
||||||
|
with the source file name and spec attributes, so if you change any of those, a
|
||||||
|
new file will be generated. The default behavior is easy!
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Like regular Django ImageFields, IK doesn't currently cache width and height
|
||||||
|
values, so accessing those will always result in a read. That will probably
|
||||||
|
change soon though.
|
||||||
|
|
||||||
|
|
||||||
|
Optimizing
|
||||||
|
==========
|
||||||
|
|
||||||
|
There are several ways to improve the performance (reduce I/O operations) of
|
||||||
|
ImageKit. Each has its own pros and cons.
|
||||||
|
|
||||||
|
|
||||||
|
Caching Data About Generated Files
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
Generally, once a file is generated, you will never be removing it, so by
|
||||||
|
default ImageKit will use default cache to cache the state of generated
|
||||||
|
files "forever" (or only 5 minutes when ``DEBUG = True``).
|
||||||
|
|
||||||
|
The time for which ImageKit will cache state is configured with
|
||||||
|
``IMAGEKIT_CACHE_TIMEOUT``. If set to ``None`` this means "never expire"
|
||||||
|
(default when ``DEBUG = False``). You can reduce this timeout if you want
|
||||||
|
or set it to some numeric value in seconds if your cache backend behaves
|
||||||
|
differently and for example do not cache values if timeout is ``None``.
|
||||||
|
|
||||||
|
If you clear your cache durring deployment or some other reason probably
|
||||||
|
you do not want to lose the cache for generated images especcialy if you
|
||||||
|
are using some slow remote storage (like Amazon S3). Then you can configure
|
||||||
|
seprate cache (for example redis) in your ``CACHES`` config and tell ImageKit
|
||||||
|
to use it instead of the default cache by setting ``IMAGEKIT_CACHE_BACKEND``.
|
||||||
|
|
||||||
|
|
||||||
|
Pre-Generating Images
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
The default cache file backend generates images immediately and synchronously.
|
||||||
|
If you don't do anything special, that will be when they are first requested—as
|
||||||
|
part of request-response cycle. This means that the first visitor to your page
|
||||||
|
will have to wait for the file to be created before they see any HTML.
|
||||||
|
|
||||||
|
This can be mitigated, though, by simply generating the images ahead of time, by
|
||||||
|
running the ``generateimages`` management command.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
If using with template tags, be sure to read :ref:`source-groups`.
|
||||||
|
|
||||||
|
|
||||||
|
Deferring Image Generation
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
As mentioned above, image generation is normally done synchronously. through
|
||||||
|
the default cache file backend. However, you can also take advantage of
|
||||||
|
deferred generation. In order to do this, you'll need to do two things:
|
||||||
|
|
||||||
|
1) install `celery`__ (or `django-celery`__ if you are bound to Celery<3.1)
|
||||||
|
2) tell ImageKit to use the async cachefile backend.
|
||||||
|
To do this for all specs, set the ``IMAGEKIT_DEFAULT_CACHEFILE_BACKEND`` in
|
||||||
|
your settings
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
IMAGEKIT_DEFAULT_CACHEFILE_BACKEND = 'imagekit.cachefiles.backends.Async'
|
||||||
|
|
||||||
|
Images will now be generated asynchronously. But watch out! Asynchrounous
|
||||||
|
generation means you'll have to account for images that haven't been generated
|
||||||
|
yet. You can do this by checking the truthiness of your files; if an image
|
||||||
|
hasn't been generated, it will be falsy:
|
||||||
|
|
||||||
|
.. code-block:: html
|
||||||
|
|
||||||
|
{% if not profile.avatar_thumbnail %}
|
||||||
|
<img src="/path/to/placeholder.jpg" />
|
||||||
|
{% else %}
|
||||||
|
<img src="{{ profile.avatar_thumbnail.url }}" />
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Or, in Python:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
profile = Profile.objects.all()[0]
|
||||||
|
if profile.avatar_thumbnail:
|
||||||
|
url = profile.avatar_thumbnail.url
|
||||||
|
else:
|
||||||
|
url = '/path/to/placeholder.jpg'
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
If you are using an "async" backend in combination with the "optimistic"
|
||||||
|
cache file strategy (see `Removing Safeguards`_ below), checking for
|
||||||
|
thruthiness as described above will not work. The "optimistic" backend is
|
||||||
|
very optimistic so to say, and removes the check. Create and use the
|
||||||
|
following strategy to a) have images only created on save, and b) retain
|
||||||
|
the ability to check whether the images have already been created::
|
||||||
|
|
||||||
|
class ImagekitOnSaveStrategy(object):
|
||||||
|
def on_source_saved(self, file):
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
If you use custom storage backend for some specs,
|
||||||
|
(storage passed to the field different than configured one)
|
||||||
|
it's required the storage to be pickleable
|
||||||
|
|
||||||
|
|
||||||
|
__ https://pypi.python.org/pypi/django-celery
|
||||||
|
__ http://www.celeryproject.org
|
||||||
|
|
||||||
|
|
||||||
|
Removing Safeguards
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Even with pre-generating images, ImageKit will still try to ensure that your
|
||||||
|
image exists when you access it by default. This is for your benefit: if you
|
||||||
|
forget to generate your images, ImageKit will see that and generate it for you.
|
||||||
|
If the state of the file is cached (see above), this is a pretty cheap
|
||||||
|
operation. However, if the state isn't cached, ImageKit will need to query the
|
||||||
|
storage backend.
|
||||||
|
|
||||||
|
For those who aren't willing to accept that cost (and who never want ImageKit
|
||||||
|
to generate images in the request-responce cycle), there's the "optimistic"
|
||||||
|
cache file strategy. This strategy only generates a new image when a spec's
|
||||||
|
source image is created or changed. Unlike with the "just in time" strategy,
|
||||||
|
accessing the file won't cause it to be generated, ImageKit will just assume
|
||||||
|
that it already exists.
|
||||||
|
|
||||||
|
To use this cache file strategy for all specs, set the
|
||||||
|
``IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY`` in your settings:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = 'imagekit.cachefiles.strategies.Optimistic'
|
||||||
|
|
||||||
|
If you have specs that :ref:`change based on attributes of the source
|
||||||
|
<dynamic-specs>`, that's not going to cut it, though; the file will also need to
|
||||||
|
be generated when those attributes change. Likewise, image generators that don't
|
||||||
|
have sources (i.e. generators that aren't specs) won't cause files to be
|
||||||
|
generated automatically when using the optimistic strategy. (ImageKit can't know
|
||||||
|
when those need to be generated, if not on access.) In both cases, you'll have
|
||||||
|
to trigger the file generation yourself—either by generating the file in code
|
||||||
|
when necessary, or by periodically running the ``generateimages`` management
|
||||||
|
command. Luckily, ImageKit makes this pretty easy:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from imagekit.cachefiles import LazyImageCacheFile
|
||||||
|
|
||||||
|
file = LazyImageCacheFile('myapp:profile:avatar_thumbnail', source=source_file)
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
One final situation in which images won't be generated automatically when using
|
||||||
|
the optimistic strategy is when you use a spec with a source that hasn't been
|
||||||
|
registered with it. Unlike the previous two examples, this situation cannot be
|
||||||
|
rectified by running the ``generateimages`` management command, for the simple
|
||||||
|
reason that the command has no way of knowing it needs to generate a file for
|
||||||
|
that spec from that source. Typically, this situation would arise when using the
|
||||||
|
template tags. Unlike ImageSpecFields, which automatically register all the
|
||||||
|
possible source images with the spec you define, the template tags
|
||||||
|
("generateimage" and "thumbnail") let you use any spec with any source.
|
||||||
|
Therefore, in order to generate the appropriate files using the
|
||||||
|
``generateimages`` management command, you'll need to first register a source
|
||||||
|
group that represents all of the sources you wish to use with the corresponding
|
||||||
|
specs. See :ref:`source-groups` for more information.
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
Changelog
|
|
||||||
=========
|
|
||||||
|
|
||||||
v2.0.2
|
|
||||||
------
|
|
||||||
|
|
||||||
- Fixed the pickling of ImageSpecFieldFile.
|
|
||||||
- Signals are now connected without specifying the class and non-IK models
|
|
||||||
are filitered out in the receivers. This is necessary beacuse of a bug
|
|
||||||
with how Django handles abstract models.
|
|
||||||
- Fixed a `ZeroDivisionError` in the Reflection processor.
|
|
||||||
- `cStringIO` is now used if it's available.
|
|
||||||
- Reflections on images now use RGBA instead of RGB.
|
|
||||||
|
|
||||||
v2.0.1
|
|
||||||
------
|
|
||||||
|
|
||||||
- Fixed a file descriptor leak in the `utils.quiet()` context manager.
|
|
||||||
|
|
||||||
|
|
||||||
v2.0.0
|
|
||||||
------
|
|
||||||
|
|
||||||
- Added the concept of image cache backends. Image cache backends assume
|
|
||||||
control of validating and invalidating the cached images from `ImageSpec` in
|
|
||||||
versions past. The default backend maintins the current behavior: invalidating
|
|
||||||
an image deletes it, while validating checks whether the file exists and
|
|
||||||
creates the file if it doesn't. One can create custom image cache backends to
|
|
||||||
control how their images are cached (e.g., Celery, etc.).
|
|
||||||
|
|
||||||
ImageKit ships with three built-in backends:
|
|
||||||
|
|
||||||
- ``imagekit.imagecache.PessimisticImageCacheBackend`` - A very safe image
|
|
||||||
cache backend. Guarantees that files will always be available, but at the
|
|
||||||
cost of hitting the storage backend.
|
|
||||||
- ``imagekit.imagecache.NonValidatingImageCacheBackend`` - A backend that is
|
|
||||||
super optimistic about the existence of spec files. It will hit your file
|
|
||||||
storage much less frequently than the pessimistic backend, but it is
|
|
||||||
technically possible for a cache file to be missing after validation.
|
|
||||||
- ``imagekit.imagecache.celery.CeleryImageCacheBackend`` - A pessimistic cache
|
|
||||||
state backend that uses celery to generate its spec images. Like
|
|
||||||
``PessimisticCacheStateBackend``, this one checks to see if the file
|
|
||||||
exists on validation, so the storage is hit fairly frequently, but an
|
|
||||||
image is guaranteed to exist. However, while validation guarantees the
|
|
||||||
existence of *an* image, it does not necessarily guarantee that you will
|
|
||||||
get the correct image, as the spec may be pending regeneration. In other
|
|
||||||
words, while there are ``generate`` tasks in the queue, it is possible to
|
|
||||||
get a stale spec image. The tradeoff is that calling ``invalidate()``
|
|
||||||
won't block to interact with file storage.
|
|
||||||
|
|
||||||
- Some of the processors have been renamed and several new ones have been added:
|
|
||||||
|
|
||||||
- ``imagekit.processors.ResizeToFill`` - (previously
|
|
||||||
``imagekit.processors.resize.Crop``) Scales the image to fill the provided
|
|
||||||
dimensions and then trims away the excess.
|
|
||||||
- ``imagekit.processors.ResizeToFit`` - (previously
|
|
||||||
``imagekit.processors.resize.Fit``) Scale to fit the provided dimensions.
|
|
||||||
- ``imagekit.processors.SmartResize`` - Like ``ResizeToFill``, but crops using
|
|
||||||
entroy (``SmartCrop``) instead of an anchor argument.
|
|
||||||
- ``imagekit.processors.BasicCrop`` - Crop using provided box.
|
|
||||||
- ``imagekit.processors.SmartCrop`` - (previously
|
|
||||||
``imagekit.processors.resize.SmartCrop``) Crop to provided size, trimming
|
|
||||||
based on entropy.
|
|
||||||
- ``imagekit.processors.TrimBorderColor`` - Trim the specified color from the
|
|
||||||
specified sides.
|
|
||||||
- ``imagekit.processors.AddBorder`` - Add a border of specific color and
|
|
||||||
thickness to an image.
|
|
||||||
- ``imagekit.processors.Resize`` - Scale to the provided dimensions (can distort).
|
|
||||||
- ``imagekit.processors.ResizeToCover`` - Scale to the smallest size that will
|
|
||||||
cover the specified dimensions. Used internally by ``Fill`` and
|
|
||||||
``SmartFill``.
|
|
||||||
- ``imagekit.processors.ResizeCanvas`` - Takes an image an resizes the canvas,
|
|
||||||
using a specific background color if the new size is larger than the current
|
|
||||||
image.
|
|
||||||
|
|
||||||
- ``mat_color`` has been added as an arguemnt to ``ResizeToFit``. If set, the
|
|
||||||
the target image size will be enforced and the specified color will be
|
|
||||||
used as background color to pad the image.
|
|
||||||
|
|
||||||
- We now use `Tox`_ to automate testing.
|
|
||||||
|
|
||||||
.. _`Tox`: http://pypi.python.org/pypi/tox
|
|
||||||
|
|
||||||
|
|
||||||
v1.1.0
|
|
||||||
------
|
|
||||||
|
|
||||||
- A ``SmartCrop`` resize processor was added. This allows an image to be
|
|
||||||
cropped based on the amount of entropy in the target image's histogram.
|
|
||||||
|
|
||||||
- The ``quality`` argument was removed in favor of an ``options`` dictionary.
|
|
||||||
This is a more general solution which grants access to PIL's format-specific
|
|
||||||
options (including "quality", "progressive", and "optimize" for JPEGs).
|
|
||||||
|
|
||||||
- The ``TrimColor`` processor was renamed to ``TrimBorderColor``.
|
|
||||||
|
|
||||||
- The private ``_Resize`` class has been removed.
|
|
||||||
|
|
||||||
|
|
||||||
v1.0.3
|
|
||||||
------
|
|
||||||
|
|
||||||
- ``ImageSpec._create()`` was renamed ``ImageSpec.generate()`` and is now
|
|
||||||
available in the public API.
|
|
||||||
|
|
||||||
- Added an ``AutoConvert`` processor to encapsulate the transparency
|
|
||||||
handling logic.
|
|
||||||
|
|
||||||
- Refactored transparency handling to be smarter, handling a lot more of
|
|
||||||
the situations in which one would convert to or from formats that support
|
|
||||||
transparency.
|
|
||||||
|
|
||||||
- Fixed PIL zeroing out files when write mode is enabled.
|
|
||||||
|
|
||||||
|
|
||||||
v1.0.2
|
|
||||||
------
|
|
||||||
|
|
||||||
- Added this changelog.
|
|
||||||
|
|
||||||
- Enhanced extension detection, format detection, and conversion between the
|
|
||||||
two. This eliminates the reliance on an image being loaded into memory
|
|
||||||
beforehand in order to detect said image's extension.
|
|
||||||
|
|
||||||
- Fixed a regression from the 0.4.x series in which ImageKit was unable to
|
|
||||||
convert a PNG file in ``P`` or "palette" mode to JPEG.
|
|
||||||
|
|
||||||
|
|
||||||
v1.0.1
|
|
||||||
------
|
|
||||||
|
|
||||||
- Minor fixes related to the rendering of ``README.rst`` as a reStructured
|
|
||||||
text file.
|
|
||||||
|
|
||||||
- Fixed the included admin template not being found when ImageKit was and
|
|
||||||
the packaging of the included admin templates.
|
|
||||||
|
|
||||||
|
|
||||||
v1.0
|
|
||||||
----
|
|
||||||
|
|
||||||
- Initial release of the *new* field-based ImageKit API.
|
|
||||||
10
docs/conf.py
10
docs/conf.py
|
|
@ -11,7 +11,7 @@
|
||||||
# All configuration values have a default; values that are commented out
|
# All configuration values have a default; values that are commented out
|
||||||
# serve to show the default.
|
# serve to show the default.
|
||||||
|
|
||||||
import sys, os
|
import re, sys, os
|
||||||
|
|
||||||
# If extensions (or modules to document with autodoc) are in another directory,
|
# If extensions (or modules to document with autodoc) are in another directory,
|
||||||
# add these directories to sys.path here. If the directory is relative to the
|
# add these directories to sys.path here. If the directory is relative to the
|
||||||
|
|
@ -45,14 +45,18 @@ master_doc = 'index'
|
||||||
project = u'ImageKit'
|
project = u'ImageKit'
|
||||||
copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'
|
copyright = u'2011, Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett & Matthew Tretter'
|
||||||
|
|
||||||
|
pkgmeta = {}
|
||||||
|
execfile(os.path.join(os.path.dirname(__file__), '..', 'imagekit',
|
||||||
|
'pkgmeta.py'), pkgmeta)
|
||||||
|
|
||||||
# The version info for the project you're documenting, acts as replacement for
|
# The version info for the project you're documenting, acts as replacement for
|
||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
version = '2.0.2'
|
version = re.match(r'\d+\.\d+', pkgmeta['__version__']).group()
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = '2.0.2'
|
release = pkgmeta['__version__']
|
||||||
|
|
||||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
|
|
|
||||||
90
docs/configuration.rst
Normal file
90
docs/configuration.rst
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
.. _settings:
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
=============
|
||||||
|
|
||||||
|
|
||||||
|
Settings
|
||||||
|
--------
|
||||||
|
|
||||||
|
.. currentmodule:: django.conf.settings
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_CACHEFILE_DIR
|
||||||
|
|
||||||
|
:default: ``'CACHE/images'``
|
||||||
|
|
||||||
|
The directory to which image files will be cached.
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_DEFAULT_FILE_STORAGE
|
||||||
|
|
||||||
|
:default: ``None``
|
||||||
|
|
||||||
|
The qualified class name of a Django storage backend to use to save the
|
||||||
|
cached images. If no value is provided for ``IMAGEKIT_DEFAULT_FILE_STORAGE``,
|
||||||
|
and none is specified by the spec definition, `your default file storage`__
|
||||||
|
will be used.
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_DEFAULT_CACHEFILE_BACKEND
|
||||||
|
|
||||||
|
:default: ``'imagekit.cachefiles.backends.Simple'``
|
||||||
|
|
||||||
|
Specifies the class that will be used to validate cached image files.
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY
|
||||||
|
|
||||||
|
:default: ``'imagekit.cachefiles.strategies.JustInTime'``
|
||||||
|
|
||||||
|
The class responsible for specifying how and when cache files are
|
||||||
|
generated.
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_CACHE_BACKEND
|
||||||
|
|
||||||
|
:default: ``'default'``
|
||||||
|
|
||||||
|
The Django cache backend alias to retrieve the shared cache instance defined
|
||||||
|
in your settings, as described in the `Django cache section`_.
|
||||||
|
|
||||||
|
The cache is then used to store information like the state of cached
|
||||||
|
images (i.e. validated or not).
|
||||||
|
|
||||||
|
.. _`Django cache section`: https://docs.djangoproject.com/en/1.8/topics/cache/#accessing-the-cache
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_CACHE_TIMEOUT
|
||||||
|
|
||||||
|
:default: ``None``
|
||||||
|
|
||||||
|
Use when you need to override the timeout used to cache file state.
|
||||||
|
By default it is "cache forever".
|
||||||
|
It's highly recommended that you use a very high timeout.
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_CACHE_PREFIX
|
||||||
|
|
||||||
|
:default: ``'imagekit:'``
|
||||||
|
|
||||||
|
A cache prefix to be used when values are stored in ``IMAGEKIT_CACHE_BACKEND``
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_CACHEFILE_NAMER
|
||||||
|
|
||||||
|
:default: ``'imagekit.cachefiles.namers.hash'``
|
||||||
|
|
||||||
|
A function responsible for generating file names for non-spec cache files.
|
||||||
|
|
||||||
|
|
||||||
|
.. attribute:: IMAGEKIT_SPEC_CACHEFILE_NAMER
|
||||||
|
|
||||||
|
:default: ``'imagekit.cachefiles.namers.source_name_as_path'``
|
||||||
|
|
||||||
|
A function responsible for generating file names for cache files that
|
||||||
|
correspond to image specs. Since you will likely want to base the name of
|
||||||
|
your cache files on the name of the source, this extra setting is provided.
|
||||||
|
|
||||||
|
|
||||||
|
__ https://docs.djangoproject.com/en/dev/ref/settings/#default-file-storage
|
||||||
|
|
@ -1,43 +1,24 @@
|
||||||
Getting Started
|
|
||||||
===============
|
|
||||||
|
|
||||||
.. include:: ../README.rst
|
.. include:: ../README.rst
|
||||||
|
|
||||||
|
|
||||||
Commands
|
|
||||||
--------
|
|
||||||
|
|
||||||
.. automodule:: imagekit.management.commands.ikcacheinvalidate
|
|
||||||
|
|
||||||
.. automodule:: imagekit.management.commands.ikcachevalidate
|
|
||||||
|
|
||||||
|
|
||||||
Authors
|
Authors
|
||||||
-------
|
=======
|
||||||
|
|
||||||
.. include:: ../AUTHORS
|
.. include:: ../AUTHORS
|
||||||
|
|
||||||
|
|
||||||
Community
|
|
||||||
---------
|
|
||||||
|
|
||||||
The official Freenode channel for ImageKit is `#imagekit <irc://irc.freenode.net/imagekit>`_.
|
|
||||||
You should always find some fine people to answer your questions
|
|
||||||
about ImageKit there.
|
|
||||||
|
|
||||||
|
|
||||||
Digging Deeper
|
|
||||||
--------------
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
|
|
||||||
apireference
|
|
||||||
changelog
|
|
||||||
|
|
||||||
|
|
||||||
Indices and tables
|
Indices and tables
|
||||||
==================
|
==================
|
||||||
|
|
||||||
* :ref:`genindex`
|
* :ref:`genindex`
|
||||||
* :ref:`modindex`
|
* :ref:`modindex`
|
||||||
* :ref:`search`
|
* :ref:`search`
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:glob:
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
configuration
|
||||||
|
advanced_usage
|
||||||
|
caching
|
||||||
|
upgrading
|
||||||
|
|
|
||||||
117
docs/upgrading.rst
Normal file
117
docs/upgrading.rst
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
Upgrading from 2.x
|
||||||
|
==================
|
||||||
|
|
||||||
|
ImageKit 3.0 introduces new APIs and tools that augment, improve, and in some
|
||||||
|
cases entirely replace old IK workflows. Below, you'll find some useful guides
|
||||||
|
for migrating your ImageKit 2.0 apps over to the shiny new IK3.
|
||||||
|
|
||||||
|
|
||||||
|
Model Specs
|
||||||
|
-----------
|
||||||
|
|
||||||
|
IK3 is chock full of new features and better tools for even the most
|
||||||
|
sophisticated use cases. Despite this, not too much has changed when it
|
||||||
|
comes to the most common of use cases: processing an ``ImageField`` on a model.
|
||||||
|
|
||||||
|
In IK2, you may have used an ``ImageSpecField`` on a model to process an
|
||||||
|
existing ``ImageField``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
avatar = models.ImageField(upload_to='avatars')
|
||||||
|
avatar_thumbnail = ImageSpecField(image_field='avatar',
|
||||||
|
processors=[ResizeToFill(100, 50)],
|
||||||
|
format='JPEG',
|
||||||
|
options={'quality': 60})
|
||||||
|
|
||||||
|
In IK3, things look much the same:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
avatar = models.ImageField(upload_to='avatars')
|
||||||
|
avatar_thumbnail = ImageSpecField(source='avatar',
|
||||||
|
processors=[ResizeToFill(100, 50)],
|
||||||
|
format='JPEG',
|
||||||
|
options={'quality': 60})
|
||||||
|
|
||||||
|
The major difference is that ``ImageSpecField`` no longer takes an
|
||||||
|
``image_field`` kwarg. Instead, you define a ``source``.
|
||||||
|
|
||||||
|
|
||||||
|
Image Cache Backends
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
In IK2, you could gain some control over how your cached images were generated
|
||||||
|
by providing an ``image_cache_backend``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Photo(models.Model):
|
||||||
|
...
|
||||||
|
thumbnail = ImageSpecField(..., image_cache_backend=MyImageCacheBackend())
|
||||||
|
|
||||||
|
This gave you great control over *how* your images are generated and stored,
|
||||||
|
but it could be difficult to control *when* they were generated and stored.
|
||||||
|
|
||||||
|
IK3 retains the image cache backend concept (now called cache file backends),
|
||||||
|
but separates the 'when' control out to cache file strategies:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
class Photo(models.Model):
|
||||||
|
...
|
||||||
|
thumbnail = ImageSpecField(...,
|
||||||
|
cachefile_backend=MyCacheFileBackend(),
|
||||||
|
cachefile_strategy=MyCacheFileStrategy())
|
||||||
|
|
||||||
|
If you are using the IK2 default image cache backend setting:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND = 'path.to.MyImageCacheBackend'
|
||||||
|
|
||||||
|
IK3 provides analogous settings for cache file backends and strategies:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
IMAGEKIT_DEFAULT_CACHEFILE_BACKEND = 'path.to.MyCacheFileBackend'
|
||||||
|
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = 'path.to.MyCacheFileStrategy'
|
||||||
|
|
||||||
|
See the documentation on :ref:`cache file backends <cache-file-backend>` and :ref:`cache file strategies <cache-file-strategy>`
|
||||||
|
for more details.
|
||||||
|
|
||||||
|
|
||||||
|
Conditional model ``processors``
|
||||||
|
--------------------------------
|
||||||
|
|
||||||
|
In IK2, an ``ImageSpecField`` could take a ``processors`` callable instead of
|
||||||
|
an iterable, which allowed processing decisions to made based on other
|
||||||
|
properties of the model. IK3 does away with this feature for consistency's sake
|
||||||
|
(if one kwarg could be callable, why not all?), but provides a much more robust
|
||||||
|
solution: the custom ``spec``. See the :doc:`advanced usage <advanced_usage>` documentation for more.
|
||||||
|
|
||||||
|
|
||||||
|
Conditonal ``cache_to`` file names
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
IK2 provided a means of specifying custom cache file names for your
|
||||||
|
image specs by passing a ``cache_to`` callable to an ``ImageSpecField``.
|
||||||
|
IK3 does away with this feature, again, for consistency.
|
||||||
|
|
||||||
|
There is a way to achieve custom file names by overriding your spec's
|
||||||
|
``cachefile_name``, but it is not recommended, as the spec's default
|
||||||
|
behavior is to hash the combination of ``source``, ``processors``, ``format``,
|
||||||
|
and other spec options to ensure that changes to the spec always result in
|
||||||
|
unique file names. See the documentation on :ref:`specs` for more.
|
||||||
|
|
||||||
|
|
||||||
|
Processors have moved to PILKit
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
Processors have moved to a separate project: `PILKit`_. You should not have to
|
||||||
|
make any changes to an IK2 project to use PILKit--it should be installed with
|
||||||
|
IK3, and importing from ``imagekit.processors`` will still work.
|
||||||
|
|
||||||
|
.. _`PILKit`: https://github.com/matthewwithanm/pilkit
|
||||||
|
|
@ -1,34 +1,6 @@
|
||||||
__title__ = 'django-imagekit'
|
# flake8: noqa
|
||||||
__author__ = 'Justin Driscoll, Bryan Veloso, Greg Newman, Chris Drackett, Matthew Tretter, Eric Eldredge'
|
from . import conf
|
||||||
__version__ = (2, 0, 2, 'final', 0)
|
from . import generatorlibrary
|
||||||
__license__ = 'BSD'
|
from .specs import ImageSpec
|
||||||
|
from .pkgmeta import *
|
||||||
|
from .registry import register, unregister
|
||||||
def get_version(version=None):
|
|
||||||
"""Derives a PEP386-compliant version number from VERSION."""
|
|
||||||
if version is None:
|
|
||||||
version = __version__
|
|
||||||
assert len(version) == 5
|
|
||||||
assert version[3] in ('alpha', 'beta', 'rc', 'final')
|
|
||||||
|
|
||||||
# Now build the two parts of the version number:
|
|
||||||
# main = X.Y[.Z]
|
|
||||||
# sub = .devN - for pre-alpha releases
|
|
||||||
# | {a|b|c}N - for alpha, beta and rc releases
|
|
||||||
|
|
||||||
parts = 2 if version[2] == 0 else 3
|
|
||||||
main = '.'.join(str(x) for x in version[:parts])
|
|
||||||
|
|
||||||
sub = ''
|
|
||||||
if version[3] == 'alpha' and version[4] == 0:
|
|
||||||
# At the toplevel, this would cause an import loop.
|
|
||||||
from django.utils.version import get_svn_revision
|
|
||||||
svn_revision = get_svn_revision()[4:]
|
|
||||||
if svn_revision != 'unknown':
|
|
||||||
sub = '.dev%s' % svn_revision
|
|
||||||
|
|
||||||
elif version[3] != 'final':
|
|
||||||
mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'c'}
|
|
||||||
sub = mapping[version[3]] + str(version[4])
|
|
||||||
|
|
||||||
return main + sub
|
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@ class AdminThumbnail(object):
|
||||||
try:
|
try:
|
||||||
thumbnail = getattr(obj, self.image_field)
|
thumbnail = getattr(obj, self.image_field)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
raise Exception('The property %s is not defined on %s.' % \
|
raise Exception('The property %s is not defined on %s.' %
|
||||||
(self.image_field, obj.__class__.__name__))
|
(self.image_field, obj.__class__.__name__))
|
||||||
|
|
||||||
original_image = getattr(thumbnail, 'source_file', None) or thumbnail
|
original_image = getattr(thumbnail, 'source', None) or thumbnail
|
||||||
template = self.template or 'imagekit/admin/thumbnail.html'
|
template = self.template or 'imagekit/admin/thumbnail.html'
|
||||||
|
|
||||||
return render_to_string(template, {
|
return render_to_string(template, {
|
||||||
|
|
|
||||||
183
imagekit/cachefiles/__init__.py
Normal file
183
imagekit/cachefiles/__init__.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
from copy import copy
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files import File
|
||||||
|
from django.core.files.images import ImageFile
|
||||||
|
from django.utils.functional import SimpleLazyObject
|
||||||
|
from django.utils.encoding import smart_str
|
||||||
|
from ..files import BaseIKFile
|
||||||
|
from ..registry import generator_registry
|
||||||
|
from ..signals import content_required, existence_required
|
||||||
|
from ..utils import get_logger, get_singleton, generate, get_by_qname
|
||||||
|
|
||||||
|
|
||||||
|
class ImageCacheFile(BaseIKFile, ImageFile):
|
||||||
|
"""
|
||||||
|
A file that represents the result of a generator. Creating an instance of
|
||||||
|
this class is not enough to trigger the generation of the file. In fact,
|
||||||
|
one of the main points of this class is to allow the creation of the file
|
||||||
|
to be deferred until the time that the cache file strategy requires it.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self, generator, name=None, storage=None, cachefile_backend=None, cachefile_strategy=None):
|
||||||
|
"""
|
||||||
|
:param generator: The object responsible for generating a new image.
|
||||||
|
:param name: The filename
|
||||||
|
:param storage: A Django storage object that will be used to save the
|
||||||
|
file.
|
||||||
|
:param cachefile_backend: The object responsible for managing the
|
||||||
|
state of the file.
|
||||||
|
:param cachefile_strategy: The object responsible for handling events
|
||||||
|
for this file.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.generator = generator
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
try:
|
||||||
|
name = generator.cachefile_name
|
||||||
|
except AttributeError:
|
||||||
|
fn = get_by_qname(settings.IMAGEKIT_CACHEFILE_NAMER, 'namer')
|
||||||
|
name = fn(generator)
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
storage = storage or getattr(generator, 'cachefile_storage',
|
||||||
|
None) or get_singleton(settings.IMAGEKIT_DEFAULT_FILE_STORAGE,
|
||||||
|
'file storage backend')
|
||||||
|
self.cachefile_backend = (
|
||||||
|
cachefile_backend
|
||||||
|
or getattr(generator, 'cachefile_backend', None)
|
||||||
|
or get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_BACKEND,
|
||||||
|
'cache file backend'))
|
||||||
|
self.cachefile_strategy = (
|
||||||
|
cachefile_strategy
|
||||||
|
or getattr(generator, 'cachefile_strategy', None)
|
||||||
|
or get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY,
|
||||||
|
'cache file strategy')
|
||||||
|
)
|
||||||
|
|
||||||
|
super(ImageCacheFile, self).__init__(storage=storage)
|
||||||
|
|
||||||
|
def _require_file(self):
|
||||||
|
if getattr(self, '_file', None) is None:
|
||||||
|
content_required.send(sender=self, file=self)
|
||||||
|
self._file = self.storage.open(self.name, 'rb')
|
||||||
|
|
||||||
|
# The ``path`` and ``url`` properties are overridden so as to not call
|
||||||
|
# ``_require_file``, which is only meant to be called when the file object
|
||||||
|
# will be directly interacted with (e.g. when using ``read()``). These only
|
||||||
|
# require the file to exist; they do not need its contents to work. This
|
||||||
|
# distinction gives the user the flexibility to create a cache file
|
||||||
|
# strategy that assumes the existence of a file, but can still make the file
|
||||||
|
# available when its contents are required.
|
||||||
|
|
||||||
|
def _storage_attr(self, attr):
|
||||||
|
if getattr(self, '_file', None) is None:
|
||||||
|
existence_required.send(sender=self, file=self)
|
||||||
|
fn = getattr(self.storage, attr)
|
||||||
|
return fn(self.name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
return self._storage_attr('path')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return self._storage_attr('url')
|
||||||
|
|
||||||
|
def generate(self, force=False):
|
||||||
|
"""
|
||||||
|
Generate the file. If ``force`` is ``True``, the file will be generated
|
||||||
|
whether the file already exists or not.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if force or getattr(self, '_file', None) is None:
|
||||||
|
self.cachefile_backend.generate(self, force)
|
||||||
|
|
||||||
|
def _generate(self):
|
||||||
|
# Generate the file
|
||||||
|
content = generate(self.generator)
|
||||||
|
|
||||||
|
actual_name = self.storage.save(self.name, content)
|
||||||
|
|
||||||
|
# We're going to reuse the generated file, so we need to reset the pointer.
|
||||||
|
content.seek(0)
|
||||||
|
|
||||||
|
# Store the generated file. If we don't do this, the next time the
|
||||||
|
# "file" attribute is accessed, it will result in a call to the storage
|
||||||
|
# backend (in ``BaseIKFile._get_file``). Since we already have the
|
||||||
|
# contents of the file, what would the point of that be?
|
||||||
|
self.file = File(content)
|
||||||
|
|
||||||
|
if actual_name != self.name:
|
||||||
|
get_logger().warning(
|
||||||
|
'The storage backend %s did not save the file with the'
|
||||||
|
' requested name ("%s") and instead used "%s". This may be'
|
||||||
|
' because a file already existed with the requested name. If'
|
||||||
|
' so, you may have meant to call generate() instead of'
|
||||||
|
' generate(force=True), or there may be a race condition in the'
|
||||||
|
' file backend %s. The saved file will not be used.' % (
|
||||||
|
self.storage,
|
||||||
|
self.name, actual_name,
|
||||||
|
self.cachefile_backend
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
if not self.name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Dispatch the existence_required signal before checking to see if the
|
||||||
|
# file exists. This gives the strategy a chance to create the file.
|
||||||
|
existence_required.send(sender=self, file=self)
|
||||||
|
|
||||||
|
try:
|
||||||
|
check = self.cachefile_strategy.should_verify_existence(self)
|
||||||
|
except AttributeError:
|
||||||
|
# All synchronous backends should have created the file as part of
|
||||||
|
# `existence_required` if they wanted to.
|
||||||
|
check = getattr(self.cachefile_backend, 'is_async', False)
|
||||||
|
return self.cachefile_backend.exists(self) if check else True
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
state = copy(self.__dict__)
|
||||||
|
|
||||||
|
# file is hidden link to "file" attribute
|
||||||
|
state.pop('_file', None)
|
||||||
|
|
||||||
|
# remove storage from state as some non-FileSystemStorage can't be
|
||||||
|
# pickled
|
||||||
|
settings_storage = get_singleton(
|
||||||
|
settings.IMAGEKIT_DEFAULT_FILE_STORAGE,
|
||||||
|
'file storage backend'
|
||||||
|
)
|
||||||
|
if state['storage'] == settings_storage:
|
||||||
|
state.pop('storage')
|
||||||
|
return state
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
if 'storage' not in state:
|
||||||
|
state['storage'] = get_singleton(
|
||||||
|
settings.IMAGEKIT_DEFAULT_FILE_STORAGE,
|
||||||
|
'file storage backend'
|
||||||
|
)
|
||||||
|
self.__dict__.update(state)
|
||||||
|
|
||||||
|
def __nonzero__(self):
|
||||||
|
# Python 2 compatibility
|
||||||
|
return self.__bool__()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return smart_str("<%s: %s>" % (
|
||||||
|
self.__class__.__name__, self if self.name else "None")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LazyImageCacheFile(SimpleLazyObject):
|
||||||
|
def __init__(self, generator_id, *args, **kwargs):
|
||||||
|
def setup():
|
||||||
|
generator = generator_registry.get(generator_id, *args, **kwargs)
|
||||||
|
return ImageCacheFile(generator)
|
||||||
|
super(LazyImageCacheFile, self).__init__(setup)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<%s: %s>' % (self.__class__.__name__, str(self) or 'None')
|
||||||
195
imagekit/cachefiles/backends.py
Normal file
195
imagekit/cachefiles/backends.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
from ..utils import get_singleton, get_cache, sanitize_cache_key
|
||||||
|
import warnings
|
||||||
|
from copy import copy
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class CacheFileState(object):
|
||||||
|
EXISTS = 'exists'
|
||||||
|
GENERATING = 'generating'
|
||||||
|
DOES_NOT_EXIST = 'does_not_exist'
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_cachefile_backend():
|
||||||
|
"""
|
||||||
|
Get the default file backend.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
return get_singleton(settings.IMAGEKIT_DEFAULT_CACHEFILE_BACKEND,
|
||||||
|
'file backend')
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidFileBackendError(ImproperlyConfigured):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractCacheFileBackend(object):
|
||||||
|
"""
|
||||||
|
An abstract cache file backend. This isn't used by any internal classes and
|
||||||
|
is included simply to illustrate the minimum interface of a cache file
|
||||||
|
backend for users who wish to implement their own.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def generate(self, file, force=False):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def exists(self, file):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class CachedFileBackend(object):
|
||||||
|
existence_check_timeout = 5
|
||||||
|
"""
|
||||||
|
The number of seconds to wait before rechecking to see if the file exists.
|
||||||
|
If the image is found to exist, that information will be cached using the
|
||||||
|
timeout specified in your CACHES setting (which should be very high).
|
||||||
|
However, when the file does not exist, you probably want to check again
|
||||||
|
in a relatively short amount of time. This attribute allows you to do that.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache(self):
|
||||||
|
if not getattr(self, '_cache', None):
|
||||||
|
self._cache = get_cache()
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
def get_key(self, file):
|
||||||
|
from django.conf import settings
|
||||||
|
return sanitize_cache_key('%s%s-state' %
|
||||||
|
(settings.IMAGEKIT_CACHE_PREFIX, file.name))
|
||||||
|
|
||||||
|
def get_state(self, file, check_if_unknown=True):
|
||||||
|
key = self.get_key(file)
|
||||||
|
state = self.cache.get(key)
|
||||||
|
if state is None and check_if_unknown:
|
||||||
|
exists = self._exists(file)
|
||||||
|
state = CacheFileState.EXISTS if exists else CacheFileState.DOES_NOT_EXIST
|
||||||
|
self.set_state(file, state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def set_state(self, file, state):
|
||||||
|
key = self.get_key(file)
|
||||||
|
if state == CacheFileState.DOES_NOT_EXIST:
|
||||||
|
self.cache.set(key, state, self.existence_check_timeout)
|
||||||
|
else:
|
||||||
|
self.cache.set(key, state, settings.IMAGEKIT_CACHE_TIMEOUT)
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
state = copy(self.__dict__)
|
||||||
|
# Don't include the cache when pickling. It'll be reconstituted based
|
||||||
|
# on the settings.
|
||||||
|
state.pop('_cache', None)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def exists(self, file):
|
||||||
|
return self.get_state(file) == CacheFileState.EXISTS
|
||||||
|
|
||||||
|
def generate(self, file, force=False):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def generate_now(self, file, force=False):
|
||||||
|
if force or self.get_state(file) not in (CacheFileState.GENERATING, CacheFileState.EXISTS):
|
||||||
|
self.set_state(file, CacheFileState.GENERATING)
|
||||||
|
file._generate()
|
||||||
|
self.set_state(file, CacheFileState.EXISTS)
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
|
||||||
|
class Simple(CachedFileBackend):
|
||||||
|
"""
|
||||||
|
The most basic file backend. The storage is consulted to see if the file
|
||||||
|
exists. Files are generated synchronously.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def generate(self, file, force=False):
|
||||||
|
self.generate_now(file, force=force)
|
||||||
|
|
||||||
|
def _exists(self, file):
|
||||||
|
return bool(getattr(file, '_file', None)
|
||||||
|
or (file.name and file.storage.exists(file.name)))
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_file(backend, file, force=False):
|
||||||
|
backend.generate_now(file, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAsync(Simple):
|
||||||
|
"""
|
||||||
|
Base class for cache file backends that generate files asynchronously.
|
||||||
|
"""
|
||||||
|
is_async = True
|
||||||
|
|
||||||
|
def generate(self, file, force=False):
|
||||||
|
# Schedule the file for generation, unless we know for sure we don't
|
||||||
|
# need to. If an already-generated file sneaks through, that's okay;
|
||||||
|
# ``generate_now`` will catch it. We just want to make sure we don't
|
||||||
|
# schedule anything we know is unnecessary--but we also don't want to
|
||||||
|
# force a costly existence check.
|
||||||
|
state = self.get_state(file, check_if_unknown=False)
|
||||||
|
if state not in (CacheFileState.GENERATING, CacheFileState.EXISTS):
|
||||||
|
self.schedule_generation(file, force=force)
|
||||||
|
|
||||||
|
def schedule_generation(self, file, force=False):
|
||||||
|
# overwrite this to have the file generated in the background,
|
||||||
|
# e. g. in a worker queue.
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from celery import task
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_celery_task = task(ignore_result=True, serializer='pickle')(_generate_file)
|
||||||
|
|
||||||
|
|
||||||
|
class Celery(BaseAsync):
|
||||||
|
"""
|
||||||
|
A backend that uses Celery to generate the images.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
import celery # noqa
|
||||||
|
except ImportError:
|
||||||
|
raise ImproperlyConfigured('You must install celery to use'
|
||||||
|
' imagekit.cachefiles.backends.Celery.')
|
||||||
|
super(Celery, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def schedule_generation(self, file, force=False):
|
||||||
|
_celery_task.delay(self, file, force=force)
|
||||||
|
|
||||||
|
|
||||||
|
# Stub class to preserve backwards compatibility and issue a warning
|
||||||
|
class Async(Celery):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
message = '{path}.Async is deprecated. Use {path}.Celery instead.'
|
||||||
|
warnings.warn(message.format(path=__name__), DeprecationWarning)
|
||||||
|
super(Async, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from django_rq import job
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_rq_job = job('default', result_ttl=0)(_generate_file)
|
||||||
|
|
||||||
|
|
||||||
|
class RQ(BaseAsync):
|
||||||
|
"""
|
||||||
|
A backend that uses RQ to generate the images.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
import django_rq # noqa
|
||||||
|
except ImportError:
|
||||||
|
raise ImproperlyConfigured('You must install django-rq to use'
|
||||||
|
' imagekit.cachefiles.backends.RQ.')
|
||||||
|
super(RQ, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def schedule_generation(self, file, force=False):
|
||||||
|
_rq_job.delay(self, file, force=force)
|
||||||
91
imagekit/cachefiles/namers.py
Normal file
91
imagekit/cachefiles/namers.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""
|
||||||
|
Functions responsible for returning filenames for the given image generator.
|
||||||
|
Users are free to define their own functions; these are just some some sensible
|
||||||
|
choices.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import os
|
||||||
|
from ..utils import format_to_extension, suggest_extension
|
||||||
|
|
||||||
|
|
||||||
|
def source_name_as_path(generator):
|
||||||
|
"""
|
||||||
|
A namer that, given the following source file name::
|
||||||
|
|
||||||
|
photos/thumbnails/bulldog.jpg
|
||||||
|
|
||||||
|
will generate a name like this::
|
||||||
|
|
||||||
|
/path/to/generated/images/photos/thumbnails/bulldog/5ff3233527c5ac3e4b596343b440ff67.jpg
|
||||||
|
|
||||||
|
where "/path/to/generated/images/" is the value specified by the
|
||||||
|
``IMAGEKIT_CACHEFILE_DIR`` setting.
|
||||||
|
|
||||||
|
"""
|
||||||
|
source_filename = getattr(generator.source, 'name', None)
|
||||||
|
|
||||||
|
if source_filename is None or os.path.isabs(source_filename):
|
||||||
|
# Generally, we put the file right in the cache file directory.
|
||||||
|
dir = settings.IMAGEKIT_CACHEFILE_DIR
|
||||||
|
else:
|
||||||
|
# For source files with relative names (like Django media files),
|
||||||
|
# use the source's name to create the new filename.
|
||||||
|
dir = os.path.join(settings.IMAGEKIT_CACHEFILE_DIR,
|
||||||
|
os.path.splitext(source_filename)[0])
|
||||||
|
|
||||||
|
ext = suggest_extension(source_filename or '', generator.format)
|
||||||
|
return os.path.normpath(os.path.join(dir,
|
||||||
|
'%s%s' % (generator.get_hash(), ext)))
|
||||||
|
|
||||||
|
|
||||||
|
def source_name_dot_hash(generator):
|
||||||
|
"""
|
||||||
|
A namer that, given the following source file name::
|
||||||
|
|
||||||
|
photos/thumbnails/bulldog.jpg
|
||||||
|
|
||||||
|
will generate a name like this::
|
||||||
|
|
||||||
|
/path/to/generated/images/photos/thumbnails/bulldog.5ff3233527c5.jpg
|
||||||
|
|
||||||
|
where "/path/to/generated/images/" is the value specified by the
|
||||||
|
``IMAGEKIT_CACHEFILE_DIR`` setting.
|
||||||
|
|
||||||
|
"""
|
||||||
|
source_filename = getattr(generator.source, 'name', None)
|
||||||
|
|
||||||
|
if source_filename is None or os.path.isabs(source_filename):
|
||||||
|
# Generally, we put the file right in the cache file directory.
|
||||||
|
dir = settings.IMAGEKIT_CACHEFILE_DIR
|
||||||
|
else:
|
||||||
|
# For source files with relative names (like Django media files),
|
||||||
|
# use the source's name to create the new filename.
|
||||||
|
dir = os.path.join(settings.IMAGEKIT_CACHEFILE_DIR,
|
||||||
|
os.path.dirname(source_filename))
|
||||||
|
|
||||||
|
ext = suggest_extension(source_filename or '', generator.format)
|
||||||
|
basename = os.path.basename(source_filename)
|
||||||
|
return os.path.normpath(os.path.join(dir, '%s.%s%s' % (
|
||||||
|
os.path.splitext(basename)[0], generator.get_hash()[:12], ext)))
|
||||||
|
|
||||||
|
|
||||||
|
def hash(generator):
|
||||||
|
"""
|
||||||
|
A namer that, given the following source file name::
|
||||||
|
|
||||||
|
photos/thumbnails/bulldog.jpg
|
||||||
|
|
||||||
|
will generate a name like this::
|
||||||
|
|
||||||
|
/path/to/generated/images/5ff3233527c5ac3e4b596343b440ff67.jpg
|
||||||
|
|
||||||
|
where "/path/to/generated/images/" is the value specified by the
|
||||||
|
``IMAGEKIT_CACHEFILE_DIR`` setting.
|
||||||
|
|
||||||
|
"""
|
||||||
|
format = getattr(generator, 'format', None)
|
||||||
|
ext = format_to_extension(format) if format else ''
|
||||||
|
return os.path.normpath(os.path.join(settings.IMAGEKIT_CACHEFILE_DIR,
|
||||||
|
'%s%s' % (generator.get_hash(), ext)))
|
||||||
49
imagekit/cachefiles/strategies.py
Normal file
49
imagekit/cachefiles/strategies.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import six
|
||||||
|
|
||||||
|
from django.utils.functional import LazyObject
|
||||||
|
from ..lib import force_text
|
||||||
|
from ..utils import get_singleton
|
||||||
|
|
||||||
|
|
||||||
|
class JustInTime(object):
|
||||||
|
"""
|
||||||
|
A strategy that ensures the file exists right before it's needed.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def on_existence_required(self, file):
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
def on_content_required(self, file):
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
|
||||||
|
class Optimistic(object):
|
||||||
|
"""
|
||||||
|
A strategy that acts immediately when the source file changes and assumes
|
||||||
|
that the cache files will not be removed (i.e. it doesn't ensure the
|
||||||
|
cache file exists when it's accessed).
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def on_source_saved(self, file):
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
def should_verify_existence(self, file):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DictStrategy(object):
|
||||||
|
def __init__(self, callbacks):
|
||||||
|
for k, v in callbacks.items():
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
|
||||||
|
def load_strategy(strategy):
|
||||||
|
if isinstance(strategy, six.string_types):
|
||||||
|
strategy = get_singleton(strategy, 'cache file strategy')
|
||||||
|
elif isinstance(strategy, dict):
|
||||||
|
strategy = DictStrategy(strategy)
|
||||||
|
elif callable(strategy):
|
||||||
|
strategy = strategy()
|
||||||
|
return strategy
|
||||||
161
imagekit/compat.py
Normal file
161
imagekit/compat.py
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
# flake8: noqa
|
||||||
|
"""
|
||||||
|
This module contains code from django.template.base
|
||||||
|
(sha 90d3af380e8efec0301dd91600c6686232de3943). Bundling this code allows us to
|
||||||
|
support older versions of Django that did not contain it (< 1.4).
|
||||||
|
|
||||||
|
|
||||||
|
Copyright (c) Django Software Foundation and individual contributors.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of Django nor the names of its contributors may be used
|
||||||
|
to endorse or promote products derived from this software without
|
||||||
|
specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.template import TemplateSyntaxError
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
# Regex for token keyword arguments
|
||||||
|
kwarg_re = re.compile(r"(?:(\w+)=)?(.+)")
|
||||||
|
|
||||||
|
|
||||||
|
def token_kwargs(bits, parser, support_legacy=False):
|
||||||
|
"""
|
||||||
|
A utility method for parsing token keyword arguments.
|
||||||
|
|
||||||
|
:param bits: A list containing remainder of the token (split by spaces)
|
||||||
|
that is to be checked for arguments. Valid arguments will be removed
|
||||||
|
from this list.
|
||||||
|
|
||||||
|
:param support_legacy: If set to true ``True``, the legacy format
|
||||||
|
``1 as foo`` will be accepted. Otherwise, only the standard ``foo=1``
|
||||||
|
format is allowed.
|
||||||
|
|
||||||
|
:returns: A dictionary of the arguments retrieved from the ``bits`` token
|
||||||
|
list.
|
||||||
|
|
||||||
|
There is no requirement for all remaining token ``bits`` to be keyword
|
||||||
|
arguments, so the dictionary will be returned as soon as an invalid
|
||||||
|
argument format is reached.
|
||||||
|
"""
|
||||||
|
if not bits:
|
||||||
|
return {}
|
||||||
|
match = kwarg_re.match(bits[0])
|
||||||
|
kwarg_format = match and match.group(1)
|
||||||
|
if not kwarg_format:
|
||||||
|
if not support_legacy:
|
||||||
|
return {}
|
||||||
|
if len(bits) < 3 or bits[1] != 'as':
|
||||||
|
return {}
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
while bits:
|
||||||
|
if kwarg_format:
|
||||||
|
match = kwarg_re.match(bits[0])
|
||||||
|
if not match or not match.group(1):
|
||||||
|
return kwargs
|
||||||
|
key, value = match.groups()
|
||||||
|
del bits[:1]
|
||||||
|
else:
|
||||||
|
if len(bits) < 3 or bits[1] != 'as':
|
||||||
|
return kwargs
|
||||||
|
key, value = bits[2], bits[0]
|
||||||
|
del bits[:3]
|
||||||
|
kwargs[key] = parser.compile_filter(value)
|
||||||
|
if bits and not kwarg_format:
|
||||||
|
if bits[0] != 'and':
|
||||||
|
return kwargs
|
||||||
|
del bits[:1]
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bits(parser, bits, params, varargs, varkw, defaults,
|
||||||
|
takes_context, name):
|
||||||
|
"""
|
||||||
|
Parses bits for template tag helpers (simple_tag, include_tag and
|
||||||
|
assignment_tag), in particular by detecting syntax errors and by
|
||||||
|
extracting positional and keyword arguments.
|
||||||
|
"""
|
||||||
|
if takes_context:
|
||||||
|
if params[0] == 'context':
|
||||||
|
params = params[1:]
|
||||||
|
else:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"'%s' is decorated with takes_context=True so it must "
|
||||||
|
"have a first argument of 'context'" % name)
|
||||||
|
args = []
|
||||||
|
kwargs = {}
|
||||||
|
unhandled_params = list(params)
|
||||||
|
for bit in bits:
|
||||||
|
# First we try to extract a potential kwarg from the bit
|
||||||
|
kwarg = token_kwargs([bit], parser)
|
||||||
|
if kwarg:
|
||||||
|
# The kwarg was successfully extracted
|
||||||
|
param, value = list(kwarg.items())[0]
|
||||||
|
if param not in params and varkw is None:
|
||||||
|
# An unexpected keyword argument was supplied
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"'%s' received unexpected keyword argument '%s'" %
|
||||||
|
(name, param))
|
||||||
|
elif param in kwargs:
|
||||||
|
# The keyword argument has already been supplied once
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"'%s' received multiple values for keyword argument '%s'" %
|
||||||
|
(name, param))
|
||||||
|
else:
|
||||||
|
# All good, record the keyword argument
|
||||||
|
kwargs[str(param)] = value
|
||||||
|
if param in unhandled_params:
|
||||||
|
# If using the keyword syntax for a positional arg, then
|
||||||
|
# consume it.
|
||||||
|
unhandled_params.remove(param)
|
||||||
|
else:
|
||||||
|
if kwargs:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"'%s' received some positional argument(s) after some "
|
||||||
|
"keyword argument(s)" % name)
|
||||||
|
else:
|
||||||
|
# Record the positional argument
|
||||||
|
args.append(parser.compile_filter(bit))
|
||||||
|
try:
|
||||||
|
# Consume from the list of expected positional arguments
|
||||||
|
unhandled_params.pop(0)
|
||||||
|
except IndexError:
|
||||||
|
if varargs is None:
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"'%s' received too many positional arguments" %
|
||||||
|
name)
|
||||||
|
if defaults is not None:
|
||||||
|
# Consider the last n params handled, where n is the
|
||||||
|
# number of defaults.
|
||||||
|
unhandled_params = unhandled_params[:-len(defaults)]
|
||||||
|
if unhandled_params:
|
||||||
|
# Some positional arguments were not supplied
|
||||||
|
raise TemplateSyntaxError(
|
||||||
|
"'%s' did not receive value(s) for the argument(s): %s" %
|
||||||
|
(name, ", ".join(["'%s'" % p for p in unhandled_params])))
|
||||||
|
return args, kwargs
|
||||||
|
|
@ -1,5 +1,40 @@
|
||||||
from appconf import AppConf
|
from appconf import AppConf
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
|
||||||
class ImageKitConf(AppConf):
|
class ImageKitConf(AppConf):
|
||||||
DEFAULT_IMAGE_CACHE_BACKEND = 'imagekit.imagecache.PessimisticImageCacheBackend'
|
CACHEFILE_NAMER = 'imagekit.cachefiles.namers.hash'
|
||||||
|
SPEC_CACHEFILE_NAMER = 'imagekit.cachefiles.namers.source_name_as_path'
|
||||||
|
CACHEFILE_DIR = 'CACHE/images'
|
||||||
|
DEFAULT_CACHEFILE_BACKEND = 'imagekit.cachefiles.backends.Simple'
|
||||||
|
DEFAULT_CACHEFILE_STRATEGY = 'imagekit.cachefiles.strategies.JustInTime'
|
||||||
|
|
||||||
|
DEFAULT_FILE_STORAGE = None
|
||||||
|
|
||||||
|
CACHE_BACKEND = None
|
||||||
|
CACHE_PREFIX = 'imagekit:'
|
||||||
|
CACHE_TIMEOUT = None
|
||||||
|
USE_MEMCACHED_SAFE_CACHE_KEY = True
|
||||||
|
|
||||||
|
def configure_cache_backend(self, value):
|
||||||
|
if value is None:
|
||||||
|
from django.core.cache import DEFAULT_CACHE_ALIAS
|
||||||
|
return DEFAULT_CACHE_ALIAS
|
||||||
|
|
||||||
|
if value not in settings.CACHES:
|
||||||
|
raise ImproperlyConfigured("{0} is not present in settings.CACHES".format(value))
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def configure_cache_timeout(self, value):
|
||||||
|
if value is None and settings.DEBUG:
|
||||||
|
# If value is not configured and is DEBUG set it to 5 minutes
|
||||||
|
return 300
|
||||||
|
# Otherwise leave it as is. If it is None then valies will never expire
|
||||||
|
return value
|
||||||
|
|
||||||
|
def configure_default_file_storage(self, value):
|
||||||
|
if value is None:
|
||||||
|
value = settings.DEFAULT_FILE_STORAGE
|
||||||
|
return value
|
||||||
|
|
|
||||||
22
imagekit/exceptions.py
Normal file
22
imagekit/exceptions.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from pilkit.exceptions import UnknownExtension, UnknownFormat
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyRegistered(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotRegistered(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MissingGeneratorId(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MissingSource(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Aliases for backwards compatibility
|
||||||
|
UnknownExtensionError = UnknownExtension
|
||||||
|
UnknownFormatError = UnknownFormat
|
||||||
110
imagekit/files.py
Normal file
110
imagekit/files.py
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.files.base import File, ContentFile
|
||||||
|
from django.utils.encoding import smart_str
|
||||||
|
from .lib import smart_text
|
||||||
|
from .utils import format_to_mimetype, extension_to_mimetype
|
||||||
|
|
||||||
|
|
||||||
|
class BaseIKFile(File):
|
||||||
|
"""
|
||||||
|
This class contains all of the methods we need from
|
||||||
|
django.db.models.fields.files.FieldFile, but with the model stuff ripped
|
||||||
|
out. It's only extended by one class, but we keep it separate for
|
||||||
|
organizational reasons.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, storage):
|
||||||
|
self.storage = storage
|
||||||
|
|
||||||
|
def _require_file(self):
|
||||||
|
if not self:
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
def _get_file(self):
|
||||||
|
self._require_file()
|
||||||
|
if not hasattr(self, '_file') or self._file is None:
|
||||||
|
self._file = self.storage.open(self.name, 'rb')
|
||||||
|
return self._file
|
||||||
|
|
||||||
|
def _set_file(self, file):
|
||||||
|
self._file = file
|
||||||
|
|
||||||
|
def _del_file(self):
|
||||||
|
del self._file
|
||||||
|
|
||||||
|
file = property(_get_file, _set_file, _del_file)
|
||||||
|
|
||||||
|
def _get_path(self):
|
||||||
|
self._require_file()
|
||||||
|
return self.storage.path(self.name)
|
||||||
|
path = property(_get_path)
|
||||||
|
|
||||||
|
def _get_url(self):
|
||||||
|
self._require_file()
|
||||||
|
return self.storage.url(self.name)
|
||||||
|
url = property(_get_url)
|
||||||
|
|
||||||
|
def _get_size(self):
|
||||||
|
self._require_file()
|
||||||
|
if not getattr(self, '_committed', False):
|
||||||
|
return self.file.size
|
||||||
|
return self.storage.size(self.name)
|
||||||
|
size = property(_get_size)
|
||||||
|
|
||||||
|
def open(self, mode='rb'):
|
||||||
|
self._require_file()
|
||||||
|
try:
|
||||||
|
self.file.open(mode)
|
||||||
|
except ValueError:
|
||||||
|
# if the underlaying file can't be reopened
|
||||||
|
# then we will use the storage to try to open it again
|
||||||
|
if self.file.closed:
|
||||||
|
# clear cached file instance
|
||||||
|
del self.file
|
||||||
|
# Because file is a property we can acces it after
|
||||||
|
# we deleted it
|
||||||
|
return self.file.open(mode)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_closed(self):
|
||||||
|
file = getattr(self, '_file', None)
|
||||||
|
return file is None or file.closed
|
||||||
|
closed = property(_get_closed)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
file = getattr(self, '_file', None)
|
||||||
|
if file is not None:
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
|
||||||
|
class IKContentFile(ContentFile):
|
||||||
|
"""
|
||||||
|
Wraps a ContentFile in a file-like object with a filename and a
|
||||||
|
content_type. A PIL image format can be optionally be provided as a content
|
||||||
|
type hint.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self, filename, content, format=None):
|
||||||
|
self.file = ContentFile(content)
|
||||||
|
self.file.name = filename
|
||||||
|
mimetype = getattr(self.file, 'content_type', None)
|
||||||
|
if format and not mimetype:
|
||||||
|
mimetype = format_to_mimetype(format)
|
||||||
|
if not mimetype:
|
||||||
|
ext = os.path.splitext(filename or '')[1]
|
||||||
|
mimetype = extension_to_mimetype(ext)
|
||||||
|
self.file.content_type = mimetype
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.file.name
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return smart_str(self.file.name or '')
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
# Python 2
|
||||||
|
return smart_text(self.file.name or '')
|
||||||
3
imagekit/forms/__init__.py
Normal file
3
imagekit/forms/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# flake8: noqa
|
||||||
|
|
||||||
|
from .fields import ProcessedImageField
|
||||||
33
imagekit/forms/fields.py
Normal file
33
imagekit/forms/fields.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from django.forms import ImageField
|
||||||
|
from ..specs import SpecHost
|
||||||
|
from ..utils import generate
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedImageField(ImageField, SpecHost):
|
||||||
|
|
||||||
|
def __init__(self, processors=None, format=None, options=None,
|
||||||
|
autoconvert=True, spec_id=None, spec=None, *args, **kwargs):
|
||||||
|
|
||||||
|
if spec_id is None:
|
||||||
|
# Unlike model fields, form fields are never told their field name.
|
||||||
|
# (Model fields are done so via `contribute_to_class()`.) Therefore
|
||||||
|
# we can't really generate a good spec id automatically.
|
||||||
|
raise TypeError('You must provide a spec_id')
|
||||||
|
|
||||||
|
SpecHost.__init__(self, processors=processors, format=format,
|
||||||
|
options=options, autoconvert=autoconvert, spec=spec,
|
||||||
|
spec_id=spec_id)
|
||||||
|
super(ProcessedImageField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self, data, initial=None):
|
||||||
|
data = super(ProcessedImageField, self).clean(data, initial)
|
||||||
|
|
||||||
|
if data and data != initial:
|
||||||
|
spec = self.get_spec(source=data)
|
||||||
|
f = generate(spec)
|
||||||
|
# Name is required in Django 1.4. When we drop support for it
|
||||||
|
# then we can dirrectly return the result from `generate(spec)`
|
||||||
|
f.name = data.name
|
||||||
|
return f
|
||||||
|
|
||||||
|
return data
|
||||||
13
imagekit/generatorlibrary.py
Normal file
13
imagekit/generatorlibrary.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from .registry import register
|
||||||
|
from .processors import Thumbnail as ThumbnailProcessor
|
||||||
|
from .specs import ImageSpec
|
||||||
|
|
||||||
|
|
||||||
|
class Thumbnail(ImageSpec):
|
||||||
|
def __init__(self, width=None, height=None, anchor=None, crop=None, upscale=None, **kwargs):
|
||||||
|
self.processors = [ThumbnailProcessor(width, height, anchor=anchor,
|
||||||
|
crop=crop, upscale=upscale)]
|
||||||
|
super(Thumbnail, self).__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
register.generator('imagekit:thumbnail', Thumbnail)
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import os
|
|
||||||
from .lib import StringIO
|
|
||||||
from .processors import ProcessorPipeline
|
|
||||||
from .utils import (img_to_fobj, open_image, IKContentFile, extension_to_format,
|
|
||||||
UnknownExtensionError)
|
|
||||||
|
|
||||||
|
|
||||||
class SpecFileGenerator(object):
|
|
||||||
def __init__(self, processors=None, format=None, options=None,
|
|
||||||
autoconvert=True, storage=None):
|
|
||||||
self.processors = processors
|
|
||||||
self.format = format
|
|
||||||
self.options = options or {}
|
|
||||||
self.autoconvert = autoconvert
|
|
||||||
self.storage = storage
|
|
||||||
|
|
||||||
def process_content(self, content, filename=None, source_file=None):
|
|
||||||
img = open_image(content)
|
|
||||||
original_format = img.format
|
|
||||||
|
|
||||||
# Run the processors
|
|
||||||
processors = self.processors
|
|
||||||
if callable(processors):
|
|
||||||
processors = processors(source_file)
|
|
||||||
img = ProcessorPipeline(processors or []).process(img)
|
|
||||||
|
|
||||||
options = dict(self.options or {})
|
|
||||||
|
|
||||||
# Determine the format.
|
|
||||||
format = self.format
|
|
||||||
if filename and not format:
|
|
||||||
# Try to guess the format from the extension.
|
|
||||||
extension = os.path.splitext(filename)[1].lower()
|
|
||||||
if extension:
|
|
||||||
try:
|
|
||||||
format = extension_to_format(extension)
|
|
||||||
except UnknownExtensionError:
|
|
||||||
pass
|
|
||||||
format = format or img.format or original_format or 'JPEG'
|
|
||||||
|
|
||||||
imgfile = img_to_fobj(img, format, **options)
|
|
||||||
content = IKContentFile(filename, imgfile.read(), format=format)
|
|
||||||
return img, content
|
|
||||||
|
|
||||||
def generate_file(self, filename, source_file, save=True):
|
|
||||||
"""
|
|
||||||
Generates a new image file by processing the source file and returns
|
|
||||||
the content of the result, ready for saving.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if source_file: # TODO: Should we error here or something if the source_file doesn't exist?
|
|
||||||
# Process the original image file.
|
|
||||||
|
|
||||||
try:
|
|
||||||
fp = source_file.storage.open(source_file.name)
|
|
||||||
except IOError:
|
|
||||||
return
|
|
||||||
fp.seek(0)
|
|
||||||
fp = StringIO(fp.read())
|
|
||||||
|
|
||||||
img, content = self.process_content(fp, filename, source_file)
|
|
||||||
|
|
||||||
if save:
|
|
||||||
storage = self.storage or source_file.storage
|
|
||||||
storage.save(filename, content)
|
|
||||||
|
|
||||||
return content
|
|
||||||
35
imagekit/hashers.py
Normal file
35
imagekit/hashers.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from copy import copy
|
||||||
|
from hashlib import md5
|
||||||
|
from pickle import MARK, DICT
|
||||||
|
try:
|
||||||
|
from pickle import _Pickler
|
||||||
|
except ImportError:
|
||||||
|
# Python 2 compatible
|
||||||
|
from pickle import Pickler as _Pickler
|
||||||
|
from .lib import StringIO
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalizingPickler(_Pickler):
|
||||||
|
dispatch = copy(_Pickler.dispatch)
|
||||||
|
|
||||||
|
def save_set(self, obj):
|
||||||
|
rv = obj.__reduce_ex__(0)
|
||||||
|
rv = (rv[0], (sorted(rv[1][0]),), rv[2])
|
||||||
|
self.save_reduce(obj=obj, *rv)
|
||||||
|
|
||||||
|
dispatch[set] = save_set
|
||||||
|
|
||||||
|
def save_dict(self, obj):
|
||||||
|
write = self.write
|
||||||
|
write(MARK + DICT)
|
||||||
|
|
||||||
|
self.memoize(obj)
|
||||||
|
self._batch_setitems(sorted(obj.items()))
|
||||||
|
|
||||||
|
dispatch[dict] = save_dict
|
||||||
|
|
||||||
|
|
||||||
|
def pickle(obj):
|
||||||
|
file = StringIO()
|
||||||
|
CanonicalizingPickler(file, 0).dump(obj)
|
||||||
|
return md5(file.getvalue()).hexdigest()
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
from django.utils.importlib import import_module
|
|
||||||
|
|
||||||
from imagekit.imagecache.base import InvalidImageCacheBackendError, PessimisticImageCacheBackend, NonValidatingImageCacheBackend
|
|
||||||
|
|
||||||
_default_image_cache_backend = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_image_cache_backend():
|
|
||||||
"""
|
|
||||||
Get the default image cache backend. Uses the same method as
|
|
||||||
django.core.file.storage.get_storage_class
|
|
||||||
|
|
||||||
"""
|
|
||||||
global _default_image_cache_backend
|
|
||||||
if not _default_image_cache_backend:
|
|
||||||
from django.conf import settings
|
|
||||||
import_path = settings.IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND
|
|
||||||
try:
|
|
||||||
dot = import_path.rindex('.')
|
|
||||||
except ValueError:
|
|
||||||
raise ImproperlyConfigured("%s isn't an image cache backend module." % \
|
|
||||||
import_path)
|
|
||||||
module, classname = import_path[:dot], import_path[dot + 1:]
|
|
||||||
try:
|
|
||||||
mod = import_module(module)
|
|
||||||
except ImportError, e:
|
|
||||||
raise ImproperlyConfigured('Error importing image cache backend module %s: "%s"' % (module, e))
|
|
||||||
try:
|
|
||||||
cls = getattr(mod, classname)
|
|
||||||
_default_image_cache_backend = cls()
|
|
||||||
except AttributeError:
|
|
||||||
raise ImproperlyConfigured('Image cache backend module "%s" does not define a "%s" class.' % (module, classname))
|
|
||||||
return _default_image_cache_backend
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidImageCacheBackendError(ImproperlyConfigured):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PessimisticImageCacheBackend(object):
|
|
||||||
"""
|
|
||||||
A very safe image cache backend. Guarantees that files will always be
|
|
||||||
available, but at the cost of hitting the storage backend.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def is_invalid(self, file):
|
|
||||||
if not getattr(file, '_file', None):
|
|
||||||
# No file on object. Have to check storage.
|
|
||||||
return not file.storage.exists(file.name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def validate(self, file):
|
|
||||||
"""
|
|
||||||
Generates a new image by running the processors on the source file.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if self.is_invalid(file):
|
|
||||||
file.generate(save=True)
|
|
||||||
|
|
||||||
def invalidate(self, file):
|
|
||||||
file.delete(save=False)
|
|
||||||
|
|
||||||
def clear(self, file):
|
|
||||||
file.delete(save=False)
|
|
||||||
|
|
||||||
|
|
||||||
class NonValidatingImageCacheBackend(object):
|
|
||||||
"""
|
|
||||||
A backend that is super optimistic about the existence of spec files. It
|
|
||||||
will hit your file storage much less frequently than the pessimistic
|
|
||||||
backend, but it is technically possible for a cache file to be missing
|
|
||||||
after validation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def validate(self, file):
|
|
||||||
"""
|
|
||||||
NonValidatingImageCacheBackend has faith, so validate's a no-op.
|
|
||||||
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def invalidate(self, file):
|
|
||||||
"""
|
|
||||||
Immediately generate a new spec file upon invalidation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
file.generate(save=True)
|
|
||||||
|
|
||||||
def clear(self, file):
|
|
||||||
file.delete(save=False)
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import
|
|
||||||
|
|
||||||
from imagekit.imagecache import PessimisticImageCacheBackend, InvalidImageCacheBackendError
|
|
||||||
|
|
||||||
|
|
||||||
def generate(model, pk, attr):
|
|
||||||
try:
|
|
||||||
instance = model._default_manager.get(pk=pk)
|
|
||||||
except model.DoesNotExist:
|
|
||||||
pass # The model was deleted since the task was scheduled. NEVER MIND!
|
|
||||||
else:
|
|
||||||
field_file = getattr(instance, attr)
|
|
||||||
field_file.delete(save=False)
|
|
||||||
field_file.generate(save=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CeleryImageCacheBackend(PessimisticImageCacheBackend):
|
|
||||||
"""
|
|
||||||
A pessimistic cache state backend that uses celery to generate its spec
|
|
||||||
images. Like PessimisticCacheStateBackend, this one checks to see if the
|
|
||||||
file exists on validation, so the storage is hit fairly frequently, but an
|
|
||||||
image is guaranteed to exist. However, while validation guarantees the
|
|
||||||
existence of *an* image, it does not necessarily guarantee that you will get
|
|
||||||
the correct image, as the spec may be pending regeneration. In other words,
|
|
||||||
while there are `generate` tasks in the queue, it is possible to get a
|
|
||||||
stale spec image. The tradeoff is that calling `invalidate()` won't block
|
|
||||||
to interact with file storage.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self):
|
|
||||||
try:
|
|
||||||
from celery.task import task
|
|
||||||
except:
|
|
||||||
raise InvalidImageCacheBackendError("Celery image cache backend requires the 'celery' library")
|
|
||||||
if not getattr(CeleryImageCacheBackend, '_task', None):
|
|
||||||
CeleryImageCacheBackend._task = task(generate)
|
|
||||||
|
|
||||||
def invalidate(self, file):
|
|
||||||
self._task.delay(file.instance.__class__, file.instance.pk, file.attname)
|
|
||||||
|
|
||||||
def clear(self, file):
|
|
||||||
file.delete(save=False)
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# flake8: noqa
|
||||||
|
|
||||||
# Required PIL classes may or may not be available from the root namespace
|
# Required PIL classes may or may not be available from the root namespace
|
||||||
# depending on the installation method used.
|
# depending on the installation method used.
|
||||||
try:
|
try:
|
||||||
|
|
@ -17,6 +19,34 @@ except ImportError:
|
||||||
raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.')
|
raise ImportError('ImageKit was unable to import the Python Imaging Library. Please confirm it`s installed and available on your current Python path.')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cStringIO import StringIO
|
from io import BytesIO as StringIO
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
from cStringIO import StringIO
|
||||||
|
except ImportError:
|
||||||
|
from StringIO import StringIO
|
||||||
|
|
||||||
|
try:
|
||||||
|
from logging import NullHandler
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from StringIO import StringIO
|
from logging import Handler
|
||||||
|
|
||||||
|
class NullHandler(Handler):
|
||||||
|
def emit(self, record):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try to import `force_text` available from Django 1.5
|
||||||
|
# This function will replace `unicode` used in the code
|
||||||
|
# If Django version is under 1.5 then use `force_unicde`
|
||||||
|
# It is used for compatibility between Python 2 and Python 3
|
||||||
|
try:
|
||||||
|
from django.utils.encoding import force_text, force_bytes, smart_text
|
||||||
|
except ImportError:
|
||||||
|
# Django < 1.5
|
||||||
|
from django.utils.encoding import (force_unicode as force_text,
|
||||||
|
smart_str as force_bytes,
|
||||||
|
smart_unicode as smart_text)
|
||||||
|
|
||||||
|
__all__ = ['Image', 'ImageColor', 'ImageChops', 'ImageEnhance', 'ImageFile',
|
||||||
|
'ImageFilter', 'ImageDraw', 'ImageStat', 'StringIO', 'NullHandler',
|
||||||
|
'force_text', 'force_bytes', 'smart_text']
|
||||||
|
|
|
||||||
53
imagekit/management/commands/generateimages.py
Normal file
53
imagekit/management/commands/generateimages.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
import re
|
||||||
|
from ...registry import generator_registry, cachefile_registry
|
||||||
|
from ...exceptions import MissingSource
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = ("""Generate files for the specified image generators (or all of them if
|
||||||
|
none was provided). Simple, glob-like wildcards are allowed, with *
|
||||||
|
matching all characters within a segment, and ** matching across
|
||||||
|
segments. (Segments are separated with colons.) So, for example,
|
||||||
|
"a:*:c" will match "a:b:c", but not "a:b:x:c", whereas "a:**:c" will
|
||||||
|
match both. Subsegments are always matched, so "a" will match "a" as
|
||||||
|
well as "a:b" and "a:b:c".""")
|
||||||
|
args = '[generator_ids]'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('generator_id', nargs='*', help='<app_name>:<model>:<field> for model specs')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
generators = generator_registry.get_ids()
|
||||||
|
|
||||||
|
generator_ids = options['generator_id'] if 'generator_id' in options else args
|
||||||
|
if generator_ids:
|
||||||
|
patterns = self.compile_patterns(generator_ids)
|
||||||
|
generators = (id for id in generators if any(p.match(id) for p in patterns))
|
||||||
|
|
||||||
|
for generator_id in generators:
|
||||||
|
self.stdout.write('Validating generator: %s\n' % generator_id)
|
||||||
|
for image_file in cachefile_registry.get(generator_id):
|
||||||
|
if image_file.name:
|
||||||
|
self.stdout.write(' %s\n' % image_file.name)
|
||||||
|
try:
|
||||||
|
image_file.generate()
|
||||||
|
except MissingSource as err:
|
||||||
|
self.stdout.write('\t No source associated with\n')
|
||||||
|
except Exception as err:
|
||||||
|
self.stdout.write('\tFailed %s\n' % (err))
|
||||||
|
|
||||||
|
def compile_patterns(self, generator_ids):
|
||||||
|
return [self.compile_pattern(id) for id in generator_ids]
|
||||||
|
|
||||||
|
def compile_pattern(self, generator_id):
|
||||||
|
parts = re.split(r'(\*{1,2})', generator_id)
|
||||||
|
pattern = ''
|
||||||
|
for part in parts:
|
||||||
|
if part == '*':
|
||||||
|
pattern += '[^:]*'
|
||||||
|
elif part == '**':
|
||||||
|
pattern += '.*'
|
||||||
|
else:
|
||||||
|
pattern += re.escape(part)
|
||||||
|
return re.compile('^%s(:.*)?$' % pattern)
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.db.models.loading import cache
|
|
||||||
from ...utils import invalidate_app_cache
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = ('Invalidates the image cache for a list of apps.')
|
|
||||||
args = '[apps]'
|
|
||||||
requires_model_validation = True
|
|
||||||
can_import_settings = True
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
apps = args or cache.app_models.keys()
|
|
||||||
invalidate_app_cache(apps)
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
from optparse import make_option
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.db.models.loading import cache
|
|
||||||
from ...utils import validate_app_cache
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = ('Validates the image cache for a list of apps.')
|
|
||||||
args = '[apps]'
|
|
||||||
requires_model_validation = True
|
|
||||||
can_import_settings = True
|
|
||||||
|
|
||||||
option_list = BaseCommand.option_list + (
|
|
||||||
make_option('--force-revalidation',
|
|
||||||
dest='force_revalidation',
|
|
||||||
action='store_true',
|
|
||||||
default=False,
|
|
||||||
help='Invalidate each image file before validating it, thereby'
|
|
||||||
' ensuring its revalidation. This is very similar to'
|
|
||||||
' running ikcacheinvalidate and then running'
|
|
||||||
' ikcachevalidate; the difference being that this option'
|
|
||||||
' causes files to be invalidated and validated'
|
|
||||||
' one-at-a-time, whereas running the two commands in series'
|
|
||||||
' would invalidate all images before validating any.'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
apps = args or cache.app_models.keys()
|
|
||||||
validate_app_cache(apps, options['force_revalidation'])
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
|
# flake8: noqa
|
||||||
|
|
||||||
from .. import conf
|
from .. import conf
|
||||||
from .fields import ImageSpecField, ProcessedImageField
|
from .fields import ImageSpecField, ProcessedImageField
|
||||||
import warnings
|
|
||||||
|
|
||||||
|
|
||||||
class ImageSpec(ImageSpecField):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
warnings.warn('ImageSpec has been moved to'
|
|
||||||
' imagekit.models.ImageSpecField. Please use that instead.',
|
|
||||||
DeprecationWarning)
|
|
||||||
super(ImageSpec, self).__init__(*args, **kwargs)
|
|
||||||
|
|
|
||||||
|
|
@ -1,107 +1,87 @@
|
||||||
import os
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.signals import class_prepared
|
||||||
from ...imagecache import get_default_image_cache_backend
|
from .files import ProcessedImageFieldFile
|
||||||
from ...generators import SpecFileGenerator
|
from .utils import ImageSpecFileDescriptor
|
||||||
from .files import ImageSpecFieldFile, ProcessedImageFieldFile
|
from ...specs import SpecHost
|
||||||
from ..receivers import configure_receivers
|
from ...specs.sourcegroups import ImageFieldSourceGroup
|
||||||
from .utils import ImageSpecFileDescriptor, ImageKitMeta, BoundImageKitMeta
|
from ...registry import register
|
||||||
from ...utils import suggest_extension
|
|
||||||
|
|
||||||
|
|
||||||
configure_receivers()
|
class SpecHostField(SpecHost):
|
||||||
|
def _set_spec_id(self, cls, name):
|
||||||
|
spec_id = getattr(self, 'spec_id', None)
|
||||||
|
|
||||||
|
# Generate a spec_id to register the spec with. The default spec id is
|
||||||
|
# "<app>:<model>_<field>"
|
||||||
|
if not spec_id:
|
||||||
|
spec_id = ('%s:%s:%s' % (cls._meta.app_label,
|
||||||
|
cls._meta.object_name, name)).lower()
|
||||||
|
|
||||||
|
# Register the spec with the id. This allows specs to be overridden
|
||||||
|
# later, from outside of the model definition.
|
||||||
|
super(SpecHostField, self).set_spec_id(spec_id)
|
||||||
|
|
||||||
|
|
||||||
class ImageSpecField(object):
|
class ImageSpecField(SpecHostField):
|
||||||
"""
|
"""
|
||||||
The heart and soul of the ImageKit library, ImageSpecField allows you to add
|
The heart and soul of the ImageKit library, ImageSpecField allows you to add
|
||||||
variants of uploaded images to your models.
|
variants of uploaded images to your models.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def __init__(self, processors=None, format=None, options=None,
|
def __init__(self, processors=None, format=None, options=None,
|
||||||
image_field=None, pre_cache=None, storage=None, cache_to=None,
|
source=None, cachefile_storage=None, autoconvert=None,
|
||||||
autoconvert=True, image_cache_backend=None):
|
cachefile_backend=None, cachefile_strategy=None, spec=None,
|
||||||
"""
|
id=None):
|
||||||
:param processors: A list of processors to run on the original image.
|
|
||||||
:param format: The format of the output file. If not provided,
|
|
||||||
ImageSpecField will try to guess the appropriate format based on the
|
|
||||||
extension of the filename and the format of the input image.
|
|
||||||
:param options: A dictionary that will be passed to PIL's
|
|
||||||
``Image.save()`` method as keyword arguments. Valid options vary
|
|
||||||
between formats, but some examples include ``quality``,
|
|
||||||
``optimize``, and ``progressive`` for JPEGs. See the PIL
|
|
||||||
documentation for others.
|
|
||||||
:param image_field: The name of the model property that contains the
|
|
||||||
original image.
|
|
||||||
:param storage: A Django storage system to use to save the generated
|
|
||||||
image.
|
|
||||||
:param cache_to: Specifies the filename to use when saving the image
|
|
||||||
cache file. This is modeled after ImageField's ``upload_to`` and
|
|
||||||
can be either a string (that specifies a directory) or a
|
|
||||||
callable (that returns a filepath). Callable values should
|
|
||||||
accept the following arguments:
|
|
||||||
|
|
||||||
- instance -- The model instance this spec belongs to
|
SpecHost.__init__(self, processors=processors, format=format,
|
||||||
- path -- The path of the original image
|
options=options, cachefile_storage=cachefile_storage,
|
||||||
- specname -- the property name that the spec is bound to on
|
autoconvert=autoconvert,
|
||||||
the model instance
|
cachefile_backend=cachefile_backend,
|
||||||
- extension -- A recommended extension. If the format of the
|
cachefile_strategy=cachefile_strategy, spec=spec,
|
||||||
spec is set explicitly, this suggestion will be
|
spec_id=id)
|
||||||
based on that format. if not, the extension of the
|
|
||||||
original file will be passed. You do not have to use
|
|
||||||
this extension, it's only a recommendation.
|
|
||||||
:param autoconvert: Specifies whether automatic conversion using
|
|
||||||
``prepare_image()`` should be performed prior to saving.
|
|
||||||
:param image_cache_backend: An object responsible for managing the state
|
|
||||||
of cached files. Defaults to an instance of
|
|
||||||
IMAGEKIT_DEFAULT_IMAGE_CACHE_BACKEND
|
|
||||||
|
|
||||||
"""
|
# TODO: Allow callable for source. See https://github.com/matthewwithanm/django-imagekit/issues/158#issuecomment-10921664
|
||||||
|
self.source = source
|
||||||
if pre_cache is not None:
|
|
||||||
raise Exception('The pre_cache argument has been removed in favor'
|
|
||||||
' of cache state backends.')
|
|
||||||
|
|
||||||
# The generator accepts a callable value for processors, but it
|
|
||||||
# takes different arguments than the callable that ImageSpecField
|
|
||||||
# expects, so we create a partial application and pass that instead.
|
|
||||||
# TODO: Should we change the signatures to match? Even if `instance` is not part of the signature, it's accessible through the source file object's instance property.
|
|
||||||
p = lambda file: processors(instance=file.instance, file=file) if \
|
|
||||||
callable(processors) else processors
|
|
||||||
|
|
||||||
self.generator = SpecFileGenerator(p, format=format, options=options,
|
|
||||||
autoconvert=autoconvert, storage=storage)
|
|
||||||
self.image_field = image_field
|
|
||||||
self.storage = storage
|
|
||||||
self.cache_to = cache_to
|
|
||||||
self.image_cache_backend = image_cache_backend or \
|
|
||||||
get_default_image_cache_backend()
|
|
||||||
|
|
||||||
def contribute_to_class(self, cls, name):
|
def contribute_to_class(self, cls, name):
|
||||||
setattr(cls, name, ImageSpecFileDescriptor(self, name))
|
# If the source field name isn't defined, figure it out.
|
||||||
try:
|
|
||||||
# Make sure we don't modify an inherited ImageKitMeta instance
|
|
||||||
ik = cls.__dict__['ik']
|
|
||||||
except KeyError:
|
|
||||||
try:
|
|
||||||
base = getattr(cls, '_ik')
|
|
||||||
except AttributeError:
|
|
||||||
ik = ImageKitMeta()
|
|
||||||
else:
|
|
||||||
# Inherit all the spec fields.
|
|
||||||
ik = ImageKitMeta(base.spec_fields)
|
|
||||||
setattr(cls, '_ik', ik)
|
|
||||||
ik.spec_fields.append(name)
|
|
||||||
|
|
||||||
# Register the field with the image_cache_backend
|
def register_source_group(source):
|
||||||
try:
|
setattr(cls, name, ImageSpecFileDescriptor(self, name, source))
|
||||||
self.image_cache_backend.register_field(cls, self, name)
|
self._set_spec_id(cls, name)
|
||||||
except AttributeError:
|
|
||||||
pass
|
# Add the model and field as a source for this spec id
|
||||||
|
register.source_group(self.spec_id, ImageFieldSourceGroup(cls, source))
|
||||||
|
|
||||||
|
if self.source:
|
||||||
|
register_source_group(self.source)
|
||||||
|
else:
|
||||||
|
# The source argument is not defined
|
||||||
|
# Then we need to see if there is only one ImageField in that model
|
||||||
|
# But we need to do that after full model initialization
|
||||||
|
def handle_model_preparation(sender, **kwargs):
|
||||||
|
|
||||||
|
image_fields = [f.attname for f in cls._meta.fields if
|
||||||
|
isinstance(f, models.ImageField)]
|
||||||
|
if len(image_fields) == 0:
|
||||||
|
raise Exception(
|
||||||
|
'%s does not define any ImageFields, so your %s'
|
||||||
|
' ImageSpecField has no image to act on.' %
|
||||||
|
(cls.__name__, name))
|
||||||
|
elif len(image_fields) > 1:
|
||||||
|
raise Exception(
|
||||||
|
'%s defines multiple ImageFields, but you have not'
|
||||||
|
' specified a source for your %s ImageSpecField.' %
|
||||||
|
(cls.__name__, name))
|
||||||
|
register_source_group(image_fields[0])
|
||||||
|
|
||||||
|
class_prepared.connect(handle_model_preparation, sender=cls, weak=False)
|
||||||
|
|
||||||
|
|
||||||
class ProcessedImageField(models.ImageField):
|
class ProcessedImageField(models.ImageField, SpecHostField):
|
||||||
"""
|
"""
|
||||||
ProcessedImageField is an ImageField that runs processors on the uploaded
|
ProcessedImageField is an ImageField that runs processors on the uploaded
|
||||||
image *before* saving it to storage. This is in contrast to specs, which
|
image *before* saving it to storage. This is in contrast to specs, which
|
||||||
|
|
@ -112,8 +92,8 @@ class ProcessedImageField(models.ImageField):
|
||||||
attr_class = ProcessedImageFieldFile
|
attr_class = ProcessedImageFieldFile
|
||||||
|
|
||||||
def __init__(self, processors=None, format=None, options=None,
|
def __init__(self, processors=None, format=None, options=None,
|
||||||
verbose_name=None, name=None, width_field=None, height_field=None,
|
verbose_name=None, name=None, width_field=None, height_field=None,
|
||||||
autoconvert=True, **kwargs):
|
autoconvert=None, spec=None, spec_id=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
The ProcessedImageField constructor accepts all of the arguments that
|
The ProcessedImageField constructor accepts all of the arguments that
|
||||||
the :class:`django.db.models.ImageField` constructor accepts, as well
|
the :class:`django.db.models.ImageField` constructor accepts, as well
|
||||||
|
|
@ -121,26 +101,26 @@ class ProcessedImageField(models.ImageField):
|
||||||
:class:`imagekit.models.ImageSpecField`.
|
:class:`imagekit.models.ImageSpecField`.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if 'quality' in kwargs:
|
# if spec is not provided then autoconvert will be True by default
|
||||||
raise Exception('The "quality" keyword argument has been'
|
if spec is None and autoconvert is None:
|
||||||
""" deprecated. Use `options={'quality': %s}` instead.""" \
|
autoconvert = True
|
||||||
% kwargs['quality'])
|
|
||||||
|
SpecHost.__init__(self, processors=processors, format=format,
|
||||||
|
options=options, autoconvert=autoconvert, spec=spec,
|
||||||
|
spec_id=spec_id)
|
||||||
models.ImageField.__init__(self, verbose_name, name, width_field,
|
models.ImageField.__init__(self, verbose_name, name, width_field,
|
||||||
height_field, **kwargs)
|
height_field, **kwargs)
|
||||||
self.generator = SpecFileGenerator(processors, format=format,
|
|
||||||
options=options, autoconvert=autoconvert)
|
|
||||||
|
|
||||||
def get_filename(self, filename):
|
def contribute_to_class(self, cls, name):
|
||||||
filename = os.path.normpath(self.storage.get_valid_name(
|
self._set_spec_id(cls, name)
|
||||||
os.path.basename(filename)))
|
return super(ProcessedImageField, self).contribute_to_class(cls, name)
|
||||||
name, ext = os.path.splitext(filename)
|
|
||||||
ext = suggest_extension(filename, self.generator.format)
|
|
||||||
return u'%s%s' % (name, ext)
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
# If the project does not use south, then we will not try to add introspection
|
||||||
from south.modelsinspector import add_introspection_rules
|
if 'south' in settings.INSTALLED_APPS:
|
||||||
except ImportError:
|
try:
|
||||||
pass
|
from south.modelsinspector import add_introspection_rules
|
||||||
else:
|
except ImportError:
|
||||||
add_introspection_rules([], [r'^imagekit\.models\.fields\.ProcessedImageField$'])
|
pass
|
||||||
|
else:
|
||||||
|
add_introspection_rules([], [r'^imagekit\.models\.fields\.ProcessedImageField$'])
|
||||||
|
|
|
||||||
|
|
@ -1,172 +1,13 @@
|
||||||
|
from django.db.models.fields.files import ImageFieldFile
|
||||||
import os
|
import os
|
||||||
import datetime
|
from ...utils import suggest_extension, generate
|
||||||
|
|
||||||
from django.db.models.fields.files import ImageField, ImageFieldFile
|
|
||||||
from django.utils.encoding import force_unicode, smart_str
|
|
||||||
|
|
||||||
from ...utils import suggest_extension
|
|
||||||
|
|
||||||
|
|
||||||
class ImageSpecFieldFile(ImageFieldFile):
|
|
||||||
def __init__(self, instance, field, attname):
|
|
||||||
super(ImageSpecFieldFile, self).__init__(instance, field, None)
|
|
||||||
self.attname = attname
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_file(self):
|
|
||||||
field_name = getattr(self.field, 'image_field', None)
|
|
||||||
if field_name:
|
|
||||||
field_file = getattr(self.instance, field_name)
|
|
||||||
else:
|
|
||||||
image_fields = [getattr(self.instance, f.attname) for f in \
|
|
||||||
self.instance.__class__._meta.fields if \
|
|
||||||
isinstance(f, ImageField)]
|
|
||||||
if len(image_fields) == 0:
|
|
||||||
raise Exception('%s does not define any ImageFields, so your' \
|
|
||||||
' %s ImageSpecField has no image to act on.' % \
|
|
||||||
(self.instance.__class__.__name__, self.attname))
|
|
||||||
elif len(image_fields) > 1:
|
|
||||||
raise Exception('%s defines multiple ImageFields, but you' \
|
|
||||||
' have not specified an image_field for your %s' \
|
|
||||||
' ImageSpecField.' % (self.instance.__class__.__name__,
|
|
||||||
self.attname))
|
|
||||||
else:
|
|
||||||
field_file = image_fields[0]
|
|
||||||
return field_file
|
|
||||||
|
|
||||||
def _require_file(self):
|
|
||||||
if not self.source_file:
|
|
||||||
raise ValueError("The '%s' attribute's image_field has no file associated with it." % self.attname)
|
|
||||||
else:
|
|
||||||
self.validate()
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
return self.field.image_cache_backend.clear(self)
|
|
||||||
|
|
||||||
def invalidate(self):
|
|
||||||
return self.field.image_cache_backend.invalidate(self)
|
|
||||||
|
|
||||||
def validate(self):
|
|
||||||
return self.field.image_cache_backend.validate(self)
|
|
||||||
|
|
||||||
def generate(self, save=True):
|
|
||||||
"""
|
|
||||||
Generates a new image file by processing the source file and returns
|
|
||||||
the content of the result, ready for saving.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self.field.generator.generate_file(self.name, self.source_file,
|
|
||||||
save)
|
|
||||||
|
|
||||||
def delete(self, save=False):
|
|
||||||
"""
|
|
||||||
Pulled almost verbatim from ``ImageFieldFile.delete()`` and
|
|
||||||
``FieldFile.delete()`` but with the attempts to reset the instance
|
|
||||||
property removed.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Clear the image dimensions cache
|
|
||||||
if hasattr(self, '_dimensions_cache'):
|
|
||||||
del self._dimensions_cache
|
|
||||||
|
|
||||||
# Only close the file if it's already open, which we know by the
|
|
||||||
# presence of self._file.
|
|
||||||
if hasattr(self, '_file'):
|
|
||||||
self.close()
|
|
||||||
del self.file
|
|
||||||
|
|
||||||
if self.name and self.storage.exists(self.name):
|
|
||||||
try:
|
|
||||||
self.storage.delete(self.name)
|
|
||||||
except NotImplementedError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Delete the filesize cache.
|
|
||||||
if hasattr(self, '_size'):
|
|
||||||
del self._size
|
|
||||||
self._committed = False
|
|
||||||
|
|
||||||
if save:
|
|
||||||
self.instance.save()
|
|
||||||
|
|
||||||
def _default_cache_to(self, instance, path, specname, extension):
|
|
||||||
"""
|
|
||||||
Determines the filename to use for the transformed image. Can be
|
|
||||||
overridden on a per-spec basis by setting the cache_to property on
|
|
||||||
the spec.
|
|
||||||
|
|
||||||
"""
|
|
||||||
filepath, basename = os.path.split(path)
|
|
||||||
filename = os.path.splitext(basename)[0]
|
|
||||||
new_name = '%s_%s%s' % (filename, specname, extension)
|
|
||||||
return os.path.join('cache', filepath, new_name)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""
|
|
||||||
Specifies the filename that the cached image will use. The user can
|
|
||||||
control this by providing a `cache_to` method to the ImageSpecField.
|
|
||||||
|
|
||||||
"""
|
|
||||||
name = getattr(self, '_name', None)
|
|
||||||
if not name:
|
|
||||||
filename = self.source_file.name
|
|
||||||
new_filename = None
|
|
||||||
if filename:
|
|
||||||
cache_to = self.field.cache_to or self._default_cache_to
|
|
||||||
|
|
||||||
if not cache_to:
|
|
||||||
raise Exception('No cache_to or default_cache_to value'
|
|
||||||
' specified')
|
|
||||||
if callable(cache_to):
|
|
||||||
suggested_extension = suggest_extension(
|
|
||||||
self.source_file.name, self.field.generator.format)
|
|
||||||
new_filename = force_unicode(
|
|
||||||
datetime.datetime.now().strftime(
|
|
||||||
smart_str(cache_to(self.instance,
|
|
||||||
self.source_file.name, self.attname,
|
|
||||||
suggested_extension))))
|
|
||||||
else:
|
|
||||||
dir_name = os.path.normpath(
|
|
||||||
force_unicode(datetime.datetime.now().strftime(
|
|
||||||
smart_str(cache_to))))
|
|
||||||
filename = os.path.normpath(os.path.basename(filename))
|
|
||||||
new_filename = os.path.join(dir_name, filename)
|
|
||||||
|
|
||||||
self._name = new_filename
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@name.setter
|
|
||||||
def name(self, value):
|
|
||||||
# TODO: Figure out a better way to handle this. We really don't want
|
|
||||||
# to allow anybody to set the name, but ``File.__init__`` (which is
|
|
||||||
# called by ``ImageSpecFieldFile.__init__``) does, so we have to allow
|
|
||||||
# it at least that one time.
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def storage(self):
|
|
||||||
return getattr(self, '_storage', None) or self.field.storage or self.source_file.storage
|
|
||||||
|
|
||||||
@storage.setter
|
|
||||||
def storage(self, storage):
|
|
||||||
self._storage = storage
|
|
||||||
|
|
||||||
def __getstate__(self):
|
|
||||||
return dict(
|
|
||||||
attname=self.attname,
|
|
||||||
instance=self.instance,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __setstate__(self, state):
|
|
||||||
self.attname = state['attname']
|
|
||||||
self.instance = state['instance']
|
|
||||||
self.field = getattr(self.instance.__class__, self.attname)
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessedImageFieldFile(ImageFieldFile):
|
class ProcessedImageFieldFile(ImageFieldFile):
|
||||||
def save(self, name, content, save=True):
|
def save(self, name, content, save=True):
|
||||||
new_filename = self.field.generate_filename(self.instance, name)
|
filename, ext = os.path.splitext(name)
|
||||||
img, content = self.field.generator.process_content(content,
|
spec = self.field.get_spec(source=content)
|
||||||
new_filename, self)
|
ext = suggest_extension(name, spec.format)
|
||||||
return super(ProcessedImageFieldFile, self).save(name, content, save)
|
new_name = '%s%s' % (filename, ext)
|
||||||
|
content = generate(spec)
|
||||||
|
return super(ProcessedImageFieldFile, self).save(new_name, content, save)
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,21 @@
|
||||||
from .files import ImageSpecFieldFile
|
from ...cachefiles import ImageCacheFile
|
||||||
|
|
||||||
|
|
||||||
class BoundImageKitMeta(object):
|
|
||||||
def __init__(self, instance, spec_fields):
|
|
||||||
self.instance = instance
|
|
||||||
self.spec_fields = spec_fields
|
|
||||||
|
|
||||||
@property
|
|
||||||
def spec_files(self):
|
|
||||||
return [getattr(self.instance, n) for n in self.spec_fields]
|
|
||||||
|
|
||||||
|
|
||||||
class ImageKitMeta(object):
|
|
||||||
def __init__(self, spec_fields=None):
|
|
||||||
self.spec_fields = list(spec_fields) if spec_fields else []
|
|
||||||
|
|
||||||
def __get__(self, instance, owner):
|
|
||||||
if instance is None:
|
|
||||||
return self
|
|
||||||
else:
|
|
||||||
ik = BoundImageKitMeta(instance, self.spec_fields)
|
|
||||||
setattr(instance, '_ik', ik)
|
|
||||||
return ik
|
|
||||||
|
|
||||||
|
|
||||||
class ImageSpecFileDescriptor(object):
|
class ImageSpecFileDescriptor(object):
|
||||||
def __init__(self, field, attname):
|
def __init__(self, field, attname, source_field_name):
|
||||||
self.attname = attname
|
self.attname = attname
|
||||||
self.field = field
|
self.field = field
|
||||||
|
self.source_field_name = source_field_name
|
||||||
|
|
||||||
def __get__(self, instance, owner):
|
def __get__(self, instance, owner):
|
||||||
if instance is None:
|
if instance is None:
|
||||||
return self.field
|
return self.field
|
||||||
else:
|
else:
|
||||||
img_spec_file = ImageSpecFieldFile(instance, self.field,
|
source = getattr(instance, self.source_field_name)
|
||||||
self.attname)
|
spec = self.field.get_spec(source=source)
|
||||||
instance.__dict__[self.attname] = img_spec_file
|
file = ImageCacheFile(spec)
|
||||||
return img_spec_file
|
instance.__dict__[self.attname] = file
|
||||||
|
return file
|
||||||
|
|
||||||
def __set__(self, instance, value):
|
def __set__(self, instance, value):
|
||||||
instance.__dict__[self.attname] = value
|
instance.__dict__[self.attname] = value
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
from django.db.models.signals import post_init, post_save, post_delete
|
|
||||||
from ..utils import ik_model_receiver
|
|
||||||
|
|
||||||
|
|
||||||
def update_source_hashes(instance):
|
|
||||||
"""
|
|
||||||
Stores hashes of the source image files so that they can be compared
|
|
||||||
later to see whether the source image has changed (and therefore whether
|
|
||||||
the spec file needs to be regenerated).
|
|
||||||
|
|
||||||
"""
|
|
||||||
instance._ik._source_hashes = dict((f.attname, hash(f.source_file)) \
|
|
||||||
for f in instance._ik.spec_files)
|
|
||||||
return instance._ik._source_hashes
|
|
||||||
|
|
||||||
|
|
||||||
@ik_model_receiver
|
|
||||||
def post_save_receiver(sender, instance=None, created=False, raw=False, **kwargs):
|
|
||||||
if not raw:
|
|
||||||
old_hashes = instance._ik._source_hashes.copy()
|
|
||||||
new_hashes = update_source_hashes(instance)
|
|
||||||
for attname in instance._ik.spec_fields:
|
|
||||||
if old_hashes[attname] != new_hashes[attname]:
|
|
||||||
getattr(instance, attname).invalidate()
|
|
||||||
|
|
||||||
|
|
||||||
@ik_model_receiver
|
|
||||||
def post_delete_receiver(sender, instance=None, **kwargs):
|
|
||||||
for spec_file in instance._ik.spec_files:
|
|
||||||
spec_file.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@ik_model_receiver
|
|
||||||
def post_init_receiver(sender, instance, **kwargs):
|
|
||||||
update_source_hashes(instance)
|
|
||||||
|
|
||||||
|
|
||||||
def configure_receivers():
|
|
||||||
# Connect the signals. We have to listen to every model (not just those
|
|
||||||
# with IK fields) and filter in our receivers because of a Django issue with
|
|
||||||
# abstract base models.
|
|
||||||
# Related:
|
|
||||||
# https://github.com/jdriscoll/django-imagekit/issues/126
|
|
||||||
# https://code.djangoproject.com/ticket/9318
|
|
||||||
uid = 'ik_spec_field_receivers'
|
|
||||||
post_init.connect(post_init_receiver, dispatch_uid=uid)
|
|
||||||
post_save.connect(post_save_receiver, dispatch_uid=uid)
|
|
||||||
post_delete.connect(post_delete_receiver, dispatch_uid=uid)
|
|
||||||
5
imagekit/pkgmeta.py
Normal file
5
imagekit/pkgmeta.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
__title__ = 'django-imagekit'
|
||||||
|
__author__ = 'Matthew Tretter, Venelin Stoykov, Eric Eldredge, Bryan Veloso, Greg Newman, Chris Drackett, Justin Driscoll'
|
||||||
|
__version__ = '4.0.2'
|
||||||
|
__license__ = 'BSD'
|
||||||
|
__all__ = ['__title__', '__author__', '__version__', '__license__']
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
"""
|
from pilkit.processors import *
|
||||||
Imagekit image processors.
|
|
||||||
|
|
||||||
A processor accepts an image, does some stuff, and returns the result.
|
__all__ = [
|
||||||
Processors can do anything with the image you want, but their responsibilities
|
# Base
|
||||||
should be limited to image manipulations--they should be completely decoupled
|
'ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose',
|
||||||
from both the filesystem and the ORM.
|
'Anchor', 'MakeOpaque',
|
||||||
|
# Crop
|
||||||
"""
|
'TrimBorderColor', 'Crop', 'SmartCrop',
|
||||||
|
# Resize
|
||||||
from .base import *
|
'Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize',
|
||||||
from .crop import *
|
'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail'
|
||||||
from .resize import *
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,209 +1,7 @@
|
||||||
from imagekit.lib import Image, ImageColor, ImageEnhance
|
import warnings
|
||||||
|
|
||||||
|
from pilkit.processors.base import *
|
||||||
|
|
||||||
class ProcessorPipeline(list):
|
warnings.warn('imagekit.processors.base is deprecated use imagekit.processors instead', DeprecationWarning)
|
||||||
"""
|
|
||||||
A :class:`list` of other processors. This class allows any object that
|
|
||||||
knows how to deal with a single processor to deal with a list of them.
|
|
||||||
For example::
|
|
||||||
|
|
||||||
processed_image = ProcessorPipeline([ProcessorA(), ProcessorB()]).process(image)
|
__all__ = ['ProcessorPipeline', 'Adjust', 'Reflection', 'Transpose', 'Anchor', 'MakeOpaque']
|
||||||
|
|
||||||
"""
|
|
||||||
def process(self, img):
|
|
||||||
for proc in self:
|
|
||||||
img = proc.process(img)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
class Adjust(object):
|
|
||||||
"""
|
|
||||||
Performs color, brightness, contrast, and sharpness enhancements on the
|
|
||||||
image. See :mod:`PIL.ImageEnhance` for more imformation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, color=1.0, brightness=1.0, contrast=1.0, sharpness=1.0):
|
|
||||||
"""
|
|
||||||
:param color: A number between 0 and 1 that specifies the saturation
|
|
||||||
of the image. 0 corresponds to a completely desaturated image
|
|
||||||
(black and white) and 1 to the original color.
|
|
||||||
See :class:`PIL.ImageEnhance.Color`
|
|
||||||
:param brightness: A number representing the brightness; 0 results in
|
|
||||||
a completely black image whereas 1 corresponds to the brightness
|
|
||||||
of the original. See :class:`PIL.ImageEnhance.Brightness`
|
|
||||||
:param contrast: A number representing the contrast; 0 results in a
|
|
||||||
completely gray image whereas 1 corresponds to the contrast of
|
|
||||||
the original. See :class:`PIL.ImageEnhance.Contrast`
|
|
||||||
:param sharpness: A number representing the sharpness; 0 results in a
|
|
||||||
blurred image; 1 corresponds to the original sharpness; 2
|
|
||||||
results in a sharpened image. See
|
|
||||||
:class:`PIL.ImageEnhance.Sharpness`
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.color = color
|
|
||||||
self.brightness = brightness
|
|
||||||
self.contrast = contrast
|
|
||||||
self.sharpness = sharpness
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
original = img = img.convert('RGBA')
|
|
||||||
for name in ['Color', 'Brightness', 'Contrast', 'Sharpness']:
|
|
||||||
factor = getattr(self, name.lower())
|
|
||||||
if factor != 1.0:
|
|
||||||
try:
|
|
||||||
img = getattr(ImageEnhance, name)(img).enhance(factor)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# PIL's Color and Contrast filters both convert the image
|
|
||||||
# to L mode, losing transparency info, so we put it back.
|
|
||||||
# See https://github.com/jdriscoll/django-imagekit/issues/64
|
|
||||||
if name in ('Color', 'Contrast'):
|
|
||||||
img = Image.merge('RGBA', img.split()[:3] +
|
|
||||||
original.split()[3:4])
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
class Reflection(object):
|
|
||||||
"""
|
|
||||||
Creates an image with a reflection.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, background_color='#FFFFFF', size=0.0, opacity=0.6):
|
|
||||||
self.background_color = background_color
|
|
||||||
self.size = size
|
|
||||||
self.opacity = opacity
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
# Convert bgcolor string to RGB value.
|
|
||||||
background_color = ImageColor.getrgb(self.background_color)
|
|
||||||
# Handle palleted images.
|
|
||||||
img = img.convert('RGBA')
|
|
||||||
# Copy orignial image and flip the orientation.
|
|
||||||
reflection = img.copy().transpose(Image.FLIP_TOP_BOTTOM)
|
|
||||||
# Create a new image filled with the bgcolor the same size.
|
|
||||||
background = Image.new("RGBA", img.size, background_color)
|
|
||||||
# Calculate our alpha mask.
|
|
||||||
start = int(255 - (255 * self.opacity)) # The start of our gradient.
|
|
||||||
steps = int(255 * self.size) # The number of intermedite values.
|
|
||||||
increment = (255 - start) / float(steps)
|
|
||||||
mask = Image.new('L', (1, 255))
|
|
||||||
for y in range(255):
|
|
||||||
if y < steps:
|
|
||||||
val = int(y * increment + start)
|
|
||||||
else:
|
|
||||||
val = 255
|
|
||||||
mask.putpixel((0, y), val)
|
|
||||||
alpha_mask = mask.resize(img.size)
|
|
||||||
# Merge the reflection onto our background color using the alpha mask.
|
|
||||||
reflection = Image.composite(background, reflection, alpha_mask)
|
|
||||||
# Crop the reflection.
|
|
||||||
reflection_height = int(img.size[1] * self.size)
|
|
||||||
reflection = reflection.crop((0, 0, img.size[0], reflection_height))
|
|
||||||
# Create new image sized to hold both the original image and
|
|
||||||
# the reflection.
|
|
||||||
composite = Image.new("RGBA", (img.size[0], img.size[1] + reflection_height), background_color)
|
|
||||||
# Paste the orignal image and the reflection into the composite image.
|
|
||||||
composite.paste(img, (0, 0))
|
|
||||||
composite.paste(reflection, (0, img.size[1]))
|
|
||||||
# Return the image complete with reflection effect.
|
|
||||||
return composite
|
|
||||||
|
|
||||||
|
|
||||||
class Transpose(object):
|
|
||||||
"""
|
|
||||||
Rotates or flips the image.
|
|
||||||
|
|
||||||
"""
|
|
||||||
AUTO = 'auto'
|
|
||||||
FLIP_HORIZONTAL = Image.FLIP_LEFT_RIGHT
|
|
||||||
FLIP_VERTICAL = Image.FLIP_TOP_BOTTOM
|
|
||||||
ROTATE_90 = Image.ROTATE_90
|
|
||||||
ROTATE_180 = Image.ROTATE_180
|
|
||||||
ROTATE_270 = Image.ROTATE_270
|
|
||||||
|
|
||||||
methods = [AUTO]
|
|
||||||
_EXIF_ORIENTATION_STEPS = {
|
|
||||||
1: [],
|
|
||||||
2: [FLIP_HORIZONTAL],
|
|
||||||
3: [ROTATE_180],
|
|
||||||
4: [FLIP_VERTICAL],
|
|
||||||
5: [ROTATE_270, FLIP_HORIZONTAL],
|
|
||||||
6: [ROTATE_270],
|
|
||||||
7: [ROTATE_90, FLIP_HORIZONTAL],
|
|
||||||
8: [ROTATE_90],
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args):
|
|
||||||
"""
|
|
||||||
Possible arguments:
|
|
||||||
- Transpose.AUTO
|
|
||||||
- Transpose.FLIP_HORIZONTAL
|
|
||||||
- Transpose.FLIP_VERTICAL
|
|
||||||
- Transpose.ROTATE_90
|
|
||||||
- Transpose.ROTATE_180
|
|
||||||
- Transpose.ROTATE_270
|
|
||||||
|
|
||||||
The order of the arguments dictates the order in which the
|
|
||||||
Transposition steps are taken.
|
|
||||||
|
|
||||||
If Transpose.AUTO is present, all other arguments are ignored, and
|
|
||||||
the processor will attempt to rotate the image according to the
|
|
||||||
EXIF Orientation data.
|
|
||||||
|
|
||||||
"""
|
|
||||||
super(Transpose, self).__init__()
|
|
||||||
if args:
|
|
||||||
self.methods = args
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
if self.AUTO in self.methods:
|
|
||||||
try:
|
|
||||||
orientation = img._getexif()[0x0112]
|
|
||||||
ops = self._EXIF_ORIENTATION_STEPS[orientation]
|
|
||||||
except (KeyError, TypeError, AttributeError):
|
|
||||||
ops = []
|
|
||||||
else:
|
|
||||||
ops = self.methods
|
|
||||||
for method in ops:
|
|
||||||
img = img.transpose(method)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
class Anchor(object):
|
|
||||||
"""
|
|
||||||
Defines all the anchor points needed by the various processor classes.
|
|
||||||
|
|
||||||
"""
|
|
||||||
TOP_LEFT = 'tl'
|
|
||||||
TOP = 't'
|
|
||||||
TOP_RIGHT = 'tr'
|
|
||||||
BOTTOM_LEFT = 'bl'
|
|
||||||
BOTTOM = 'b'
|
|
||||||
BOTTOM_RIGHT = 'br'
|
|
||||||
CENTER = 'c'
|
|
||||||
LEFT = 'l'
|
|
||||||
RIGHT = 'r'
|
|
||||||
|
|
||||||
_ANCHOR_PTS = {
|
|
||||||
TOP_LEFT: (0, 0),
|
|
||||||
TOP: (0.5, 0),
|
|
||||||
TOP_RIGHT: (1, 0),
|
|
||||||
LEFT: (0, 0.5),
|
|
||||||
CENTER: (0.5, 0.5),
|
|
||||||
RIGHT: (1, 0.5),
|
|
||||||
BOTTOM_LEFT: (0, 1),
|
|
||||||
BOTTOM: (0.5, 1),
|
|
||||||
BOTTOM_RIGHT: (1, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_tuple(anchor):
|
|
||||||
"""Normalizes anchor values (strings or tuples) to tuples.
|
|
||||||
|
|
||||||
"""
|
|
||||||
# If the user passed in one of the string values, convert it to a
|
|
||||||
# percentage tuple.
|
|
||||||
if anchor in Anchor._ANCHOR_PTS.keys():
|
|
||||||
anchor = Anchor._ANCHOR_PTS[anchor]
|
|
||||||
return anchor
|
|
||||||
|
|
|
||||||
|
|
@ -1,170 +1,7 @@
|
||||||
from .base import Anchor
|
import warnings
|
||||||
from .utils import histogram_entropy
|
|
||||||
from ..lib import Image, ImageChops, ImageDraw, ImageStat
|
|
||||||
|
|
||||||
|
from pilkit.processors.crop import *
|
||||||
|
|
||||||
class Side(object):
|
warnings.warn('imagekit.processors.crop is deprecated use imagekit.processors instead', DeprecationWarning)
|
||||||
TOP = 't'
|
|
||||||
RIGHT = 'r'
|
|
||||||
BOTTOM = 'b'
|
|
||||||
LEFT = 'l'
|
|
||||||
ALL = (TOP, RIGHT, BOTTOM, LEFT)
|
|
||||||
|
|
||||||
|
__all__ = ['TrimBorderColor', 'Crop', 'SmartCrop']
|
||||||
def _crop(img, bbox, sides=Side.ALL):
|
|
||||||
bbox = (
|
|
||||||
bbox[0] if Side.LEFT in sides else 0,
|
|
||||||
bbox[1] if Side.TOP in sides else 0,
|
|
||||||
bbox[2] if Side.RIGHT in sides else img.size[0],
|
|
||||||
bbox[3] if Side.BOTTOM in sides else img.size[1],
|
|
||||||
)
|
|
||||||
return img.crop(bbox)
|
|
||||||
|
|
||||||
|
|
||||||
def detect_border_color(img):
|
|
||||||
mask = Image.new('1', img.size, 1)
|
|
||||||
w, h = img.size[0] - 2, img.size[1] - 2
|
|
||||||
if w > 0 and h > 0:
|
|
||||||
draw = ImageDraw.Draw(mask)
|
|
||||||
draw.rectangle([1, 1, w, h], 0)
|
|
||||||
return ImageStat.Stat(img.convert('RGBA').histogram(mask)).median
|
|
||||||
|
|
||||||
|
|
||||||
class TrimBorderColor(object):
|
|
||||||
"""Trims a color from the sides of an image.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, color=None, tolerance=0.3, sides=Side.ALL):
|
|
||||||
"""
|
|
||||||
:param color: The color to trim from the image, in a 4-tuple RGBA value,
|
|
||||||
where each component is an integer between 0 and 255, inclusive. If
|
|
||||||
no color is provided, the processor will attempt to detect the
|
|
||||||
border color automatically.
|
|
||||||
:param tolerance: A number between 0 and 1 where 0. Zero is the least
|
|
||||||
tolerant and one is the most.
|
|
||||||
:param sides: A list of sides that should be trimmed. Possible values
|
|
||||||
are provided by the :class:`Side` enum class.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.color = color
|
|
||||||
self.sides = sides
|
|
||||||
self.tolerance = tolerance
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
source = img.convert('RGBA')
|
|
||||||
border_color = self.color or tuple(detect_border_color(source))
|
|
||||||
bg = Image.new('RGBA', img.size, border_color)
|
|
||||||
diff = ImageChops.difference(source, bg)
|
|
||||||
if self.tolerance not in (0, 1):
|
|
||||||
# If tolerance is zero, we've already done the job. A tolerance of
|
|
||||||
# one would mean to trim EVERY color, and since that would result
|
|
||||||
# in a zero-sized image, we just ignore it.
|
|
||||||
if not 0 <= self.tolerance <= 1:
|
|
||||||
raise ValueError('%s is an invalid tolerance. Acceptable values'
|
|
||||||
' are between 0 and 1 (inclusive).' % self.tolerance)
|
|
||||||
tmp = ImageChops.constant(diff, int(self.tolerance * 255)) \
|
|
||||||
.convert('RGBA')
|
|
||||||
diff = ImageChops.subtract(diff, tmp)
|
|
||||||
|
|
||||||
bbox = diff.getbbox()
|
|
||||||
if bbox:
|
|
||||||
img = _crop(img, bbox, self.sides)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
class Crop(object):
|
|
||||||
"""
|
|
||||||
Crops an image, cropping it to the specified width and height. You may
|
|
||||||
optionally provide either an anchor or x and y coordinates. This processor
|
|
||||||
functions exactly the same as ``ResizeCanvas`` except that it will never
|
|
||||||
enlarge the image.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, width=None, height=None, anchor=None, x=None, y=None):
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.anchor = anchor
|
|
||||||
self.x = x
|
|
||||||
self.y = y
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
from .resize import ResizeCanvas
|
|
||||||
|
|
||||||
original_width, original_height = img.size
|
|
||||||
new_width, new_height = min(original_width, self.width), \
|
|
||||||
min(original_height, self.height)
|
|
||||||
|
|
||||||
return ResizeCanvas(new_width, new_height, anchor=self.anchor,
|
|
||||||
x=self.x, y=self.y).process(img)
|
|
||||||
|
|
||||||
|
|
||||||
class SmartCrop(object):
|
|
||||||
"""
|
|
||||||
Crop an image to the specified dimensions, whittling away the parts of the
|
|
||||||
image with the least entropy.
|
|
||||||
|
|
||||||
Based on smart crop implementation from easy-thumbnails:
|
|
||||||
https://github.com/SmileyChris/easy-thumbnails/blob/master/easy_thumbnails/processors.py#L193
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, width=None, height=None):
|
|
||||||
"""
|
|
||||||
:param width: The target width, in pixels.
|
|
||||||
:param height: The target height, in pixels.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def compare_entropy(self, start_slice, end_slice, slice, difference):
|
|
||||||
"""
|
|
||||||
Calculate the entropy of two slices (from the start and end of an axis),
|
|
||||||
returning a tuple containing the amount that should be added to the start
|
|
||||||
and removed from the end of the axis.
|
|
||||||
|
|
||||||
"""
|
|
||||||
start_entropy = histogram_entropy(start_slice)
|
|
||||||
end_entropy = histogram_entropy(end_slice)
|
|
||||||
|
|
||||||
if end_entropy and abs(start_entropy / end_entropy - 1) < 0.01:
|
|
||||||
# Less than 1% difference, remove from both sides.
|
|
||||||
if difference >= slice * 2:
|
|
||||||
return slice, slice
|
|
||||||
half_slice = slice // 2
|
|
||||||
return half_slice, slice - half_slice
|
|
||||||
|
|
||||||
if start_entropy > end_entropy:
|
|
||||||
return 0, slice
|
|
||||||
else:
|
|
||||||
return slice, 0
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
source_x, source_y = img.size
|
|
||||||
diff_x = int(source_x - min(source_x, self.width))
|
|
||||||
diff_y = int(source_y - min(source_y, self.height))
|
|
||||||
left = top = 0
|
|
||||||
right, bottom = source_x, source_y
|
|
||||||
|
|
||||||
while diff_x:
|
|
||||||
slice = min(diff_x, max(diff_x // 5, 10))
|
|
||||||
start = img.crop((left, 0, left + slice, source_y))
|
|
||||||
end = img.crop((right - slice, 0, right, source_y))
|
|
||||||
add, remove = self.compare_entropy(start, end, slice, diff_x)
|
|
||||||
left += add
|
|
||||||
right -= remove
|
|
||||||
diff_x = diff_x - add - remove
|
|
||||||
|
|
||||||
while diff_y:
|
|
||||||
slice = min(diff_y, max(diff_y // 5, 10))
|
|
||||||
start = img.crop((0, top, source_x, top + slice))
|
|
||||||
end = img.crop((0, bottom - slice, source_x, bottom))
|
|
||||||
add, remove = self.compare_entropy(start, end, slice, diff_y)
|
|
||||||
top += add
|
|
||||||
bottom -= remove
|
|
||||||
diff_y = diff_y - add - remove
|
|
||||||
|
|
||||||
box = (left, top, right, bottom)
|
|
||||||
img = img.crop(box)
|
|
||||||
return img
|
|
||||||
|
|
|
||||||
|
|
@ -1,220 +1,7 @@
|
||||||
from imagekit.lib import Image
|
|
||||||
import warnings
|
import warnings
|
||||||
from .base import Anchor
|
|
||||||
|
|
||||||
|
from pilkit.processors.resize import *
|
||||||
|
|
||||||
class Resize(object):
|
warnings.warn('imagekit.processors.resize is deprecated use imagekit.processors instead', DeprecationWarning)
|
||||||
"""
|
|
||||||
Resizes an image to the specified width and height.
|
|
||||||
|
|
||||||
"""
|
__all__ = ['Resize', 'ResizeToCover', 'ResizeToFill', 'SmartResize', 'ResizeCanvas', 'AddBorder', 'ResizeToFit', 'Thumbnail']
|
||||||
def __init__(self, width, height):
|
|
||||||
"""
|
|
||||||
:param width: The target width, in pixels.
|
|
||||||
:param height: The target height, in pixels.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
return img.resize((self.width, self.height), Image.ANTIALIAS)
|
|
||||||
|
|
||||||
|
|
||||||
class ResizeToCover(object):
|
|
||||||
"""
|
|
||||||
Resizes the image to the smallest possible size that will entirely cover the
|
|
||||||
provided dimensions. You probably won't be using this processor directly,
|
|
||||||
but it's used internally by ``ResizeToFill`` and ``SmartResize``.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, width, height):
|
|
||||||
"""
|
|
||||||
:param width: The target width, in pixels.
|
|
||||||
:param height: The target height, in pixels.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.width, self.height = width, height
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
original_width, original_height = img.size
|
|
||||||
ratio = max(float(self.width) / original_width,
|
|
||||||
float(self.height) / original_height)
|
|
||||||
new_width, new_height = (int(original_width * ratio),
|
|
||||||
int(original_height * ratio))
|
|
||||||
return Resize(new_width, new_height).process(img)
|
|
||||||
|
|
||||||
|
|
||||||
class ResizeToFill(object):
|
|
||||||
"""
|
|
||||||
Resizes an image, cropping it to the exact specified width and height.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, width=None, height=None, anchor=None):
|
|
||||||
"""
|
|
||||||
:param width: The target width, in pixels.
|
|
||||||
:param height: The target height, in pixels.
|
|
||||||
:param anchor: Specifies which part of the image should be retained
|
|
||||||
when cropping.
|
|
||||||
"""
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.anchor = anchor
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
from .crop import Crop
|
|
||||||
img = ResizeToCover(self.width, self.height).process(img)
|
|
||||||
return Crop(self.width, self.height,
|
|
||||||
anchor=self.anchor).process(img)
|
|
||||||
|
|
||||||
|
|
||||||
class SmartResize(object):
|
|
||||||
"""
|
|
||||||
The ``SmartResize`` processor is identical to ``ResizeToFill``, except that
|
|
||||||
it uses entropy to crop the image instead of a user-specified anchor point.
|
|
||||||
Internally, it simply runs the ``ResizeToCover`` and ``SmartCrop``
|
|
||||||
processors in series.
|
|
||||||
"""
|
|
||||||
def __init__(self, width, height):
|
|
||||||
"""
|
|
||||||
:param width: The target width, in pixels.
|
|
||||||
:param height: The target height, in pixels.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.width, self.height = width, height
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
from .crop import SmartCrop
|
|
||||||
img = ResizeToCover(self.width, self.height).process(img)
|
|
||||||
return SmartCrop(self.width, self.height).process(img)
|
|
||||||
|
|
||||||
|
|
||||||
class ResizeCanvas(object):
|
|
||||||
"""
|
|
||||||
Resizes the canvas, using the provided background color if the new size is
|
|
||||||
larger than the current image.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, width, height, color=None, anchor=None, x=None, y=None):
|
|
||||||
"""
|
|
||||||
:param width: The target width, in pixels.
|
|
||||||
:param height: The target height, in pixels.
|
|
||||||
:param color: The background color to use for padding.
|
|
||||||
:param anchor: Specifies the position of the original image on the new
|
|
||||||
canvas. Valid values are:
|
|
||||||
|
|
||||||
- Anchor.TOP_LEFT
|
|
||||||
- Anchor.TOP
|
|
||||||
- Anchor.TOP_RIGHT
|
|
||||||
- Anchor.LEFT
|
|
||||||
- Anchor.CENTER
|
|
||||||
- Anchor.RIGHT
|
|
||||||
- Anchor.BOTTOM_LEFT
|
|
||||||
- Anchor.BOTTOM
|
|
||||||
- Anchor.BOTTOM_RIGHT
|
|
||||||
|
|
||||||
You may also pass a tuple that indicates the position in
|
|
||||||
percentages. For example, ``(0, 0)`` corresponds to "top left",
|
|
||||||
``(0.5, 0.5)`` to "center" and ``(1, 1)`` to "bottom right". This is
|
|
||||||
basically the same as using percentages in CSS background positions.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if x is not None or y is not None:
|
|
||||||
if anchor:
|
|
||||||
raise Exception('You may provide either an anchor or x and y'
|
|
||||||
' coordinate, but not both.')
|
|
||||||
else:
|
|
||||||
self.x, self.y = x or 0, y or 0
|
|
||||||
self.anchor = None
|
|
||||||
else:
|
|
||||||
self.anchor = anchor or Anchor.CENTER
|
|
||||||
self.x = self.y = None
|
|
||||||
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.color = color or (255, 255, 255, 0)
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
original_width, original_height = img.size
|
|
||||||
|
|
||||||
if self.anchor:
|
|
||||||
anchor = Anchor.get_tuple(self.anchor)
|
|
||||||
trim_x, trim_y = self.width - original_width, \
|
|
||||||
self.height - original_height
|
|
||||||
x = int(float(trim_x) * float(anchor[0]))
|
|
||||||
y = int(float(trim_y) * float(anchor[1]))
|
|
||||||
else:
|
|
||||||
x, y = self.x, self.y
|
|
||||||
|
|
||||||
new_img = Image.new('RGBA', (self.width, self.height), self.color)
|
|
||||||
new_img.paste(img, (x, y))
|
|
||||||
return new_img
|
|
||||||
|
|
||||||
|
|
||||||
class AddBorder(object):
|
|
||||||
"""
|
|
||||||
Add a border of specific color and size to an image.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, thickness, color=None):
|
|
||||||
"""
|
|
||||||
:param color: Color to use for the border
|
|
||||||
:param thickness: Thickness of the border. Can be either an int or
|
|
||||||
a 4-tuple of ints of the form (top, right, bottom, left).
|
|
||||||
"""
|
|
||||||
self.color = color
|
|
||||||
if isinstance(thickness, int):
|
|
||||||
self.top = self.right = self.bottom = self.left = thickness
|
|
||||||
else:
|
|
||||||
self.top, self.right, self.bottom, self.left = thickness
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
new_width = img.size[0] + self.left + self.right
|
|
||||||
new_height = img.size[1] + self.top + self.bottom
|
|
||||||
return ResizeCanvas(new_width, new_height, color=self.color,
|
|
||||||
x=self.left, y=self.top).process(img)
|
|
||||||
|
|
||||||
|
|
||||||
class ResizeToFit(object):
|
|
||||||
"""
|
|
||||||
Resizes an image to fit within the specified dimensions.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, width=None, height=None, upscale=None, mat_color=None, anchor=Anchor.CENTER):
|
|
||||||
"""
|
|
||||||
:param width: The maximum width of the desired image.
|
|
||||||
:param height: The maximum height of the desired image.
|
|
||||||
:param upscale: A boolean value specifying whether the image should
|
|
||||||
be enlarged if its dimensions are smaller than the target
|
|
||||||
dimensions.
|
|
||||||
:param mat_color: If set, the target image size will be enforced and the
|
|
||||||
specified color will be used as a background color to pad the image.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.upscale = upscale
|
|
||||||
self.mat_color = mat_color
|
|
||||||
self.anchor = anchor
|
|
||||||
|
|
||||||
def process(self, img):
|
|
||||||
cur_width, cur_height = img.size
|
|
||||||
if not self.width is None and not self.height is None:
|
|
||||||
ratio = min(float(self.width) / cur_width,
|
|
||||||
float(self.height) / cur_height)
|
|
||||||
else:
|
|
||||||
if self.width is None:
|
|
||||||
ratio = float(self.height) / cur_height
|
|
||||||
else:
|
|
||||||
ratio = float(self.width) / cur_width
|
|
||||||
new_dimensions = (int(round(cur_width * ratio)),
|
|
||||||
int(round(cur_height * ratio)))
|
|
||||||
if (cur_width > new_dimensions[0] or cur_height > new_dimensions[1]) or \
|
|
||||||
self.upscale:
|
|
||||||
img = Resize(new_dimensions[0],
|
|
||||||
new_dimensions[1]).process(img)
|
|
||||||
if self.mat_color:
|
|
||||||
img = ResizeCanvas(self.width, self.height, self.mat_color, anchor=self.anchor).process(img)
|
|
||||||
return img
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,5 @@
|
||||||
import math
|
import warnings
|
||||||
from imagekit.lib import Image
|
|
||||||
|
|
||||||
|
from pilkit.processors.utils import *
|
||||||
|
|
||||||
def histogram_entropy(im):
|
warnings.warn('imagekit.processors.utils is deprecated use pilkit.processors.utils instead', DeprecationWarning)
|
||||||
"""
|
|
||||||
Calculate the entropy of an images' histogram. Used for "smart cropping" in easy-thumbnails;
|
|
||||||
see: https://raw.github.com/SmileyChris/easy-thumbnails/master/easy_thumbnails/utils.py
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not isinstance(im, Image.Image):
|
|
||||||
return 0 # Fall back to a constant entropy.
|
|
||||||
|
|
||||||
histogram = im.histogram()
|
|
||||||
hist_ceil = float(sum(histogram))
|
|
||||||
histonorm = [histocol / hist_ceil for histocol in histogram]
|
|
||||||
|
|
||||||
return -sum([p * math.log(p, 2) for p in histonorm if p != 0])
|
|
||||||
|
|
|
||||||
201
imagekit/registry.py
Normal file
201
imagekit/registry.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
from .exceptions import AlreadyRegistered, NotRegistered
|
||||||
|
from .signals import content_required, existence_required, source_saved
|
||||||
|
from .utils import autodiscover, call_strategy_method
|
||||||
|
|
||||||
|
|
||||||
|
class GeneratorRegistry(object):
|
||||||
|
"""
|
||||||
|
An object for registering generators. This registry provides
|
||||||
|
a convenient way for a distributable app to define default generators
|
||||||
|
without locking the users of the app into it.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self._generators = {}
|
||||||
|
content_required.connect(self.content_required_receiver)
|
||||||
|
existence_required.connect(self.existence_required_receiver)
|
||||||
|
|
||||||
|
def register(self, id, generator):
|
||||||
|
registered_generator = self._generators.get(id)
|
||||||
|
if registered_generator and generator != self._generators[id]:
|
||||||
|
raise AlreadyRegistered('The generator with id %s is'
|
||||||
|
' already registered' % id)
|
||||||
|
self._generators[id] = generator
|
||||||
|
|
||||||
|
def unregister(self, id):
|
||||||
|
try:
|
||||||
|
del self._generators[id]
|
||||||
|
except KeyError:
|
||||||
|
raise NotRegistered('The generator with id %s is not'
|
||||||
|
' registered' % id)
|
||||||
|
|
||||||
|
def get(self, id, **kwargs):
|
||||||
|
autodiscover()
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator = self._generators[id]
|
||||||
|
except KeyError:
|
||||||
|
raise NotRegistered('The generator with id %s is not'
|
||||||
|
' registered' % id)
|
||||||
|
if callable(generator):
|
||||||
|
return generator(**kwargs)
|
||||||
|
else:
|
||||||
|
return generator
|
||||||
|
|
||||||
|
def get_ids(self):
|
||||||
|
autodiscover()
|
||||||
|
return self._generators.keys()
|
||||||
|
|
||||||
|
def content_required_receiver(self, sender, file, **kwargs):
|
||||||
|
self._receive(file, 'on_content_required')
|
||||||
|
|
||||||
|
def existence_required_receiver(self, sender, file, **kwargs):
|
||||||
|
self._receive(file, 'on_existence_required')
|
||||||
|
|
||||||
|
def _receive(self, file, callback):
|
||||||
|
generator = file.generator
|
||||||
|
|
||||||
|
# FIXME: I guess this means you can't register functions?
|
||||||
|
if generator.__class__ in self._generators.values():
|
||||||
|
# Only invoke the strategy method for registered generators.
|
||||||
|
call_strategy_method(file, callback)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceGroupRegistry(object):
|
||||||
|
"""
|
||||||
|
The source group registry is responsible for listening to source_* signals
|
||||||
|
on source groups, and relaying them to the image generated file strategies
|
||||||
|
of the appropriate generators.
|
||||||
|
|
||||||
|
In addition, registering a new source group also registers its generated
|
||||||
|
files with that registry.
|
||||||
|
|
||||||
|
"""
|
||||||
|
_signals = {
|
||||||
|
source_saved: 'on_source_saved',
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._source_groups = {}
|
||||||
|
for signal in self._signals.keys():
|
||||||
|
signal.connect(self.source_group_receiver)
|
||||||
|
|
||||||
|
def register(self, generator_id, source_group):
|
||||||
|
from .specs.sourcegroups import SourceGroupFilesGenerator
|
||||||
|
generator_ids = self._source_groups.setdefault(source_group, set())
|
||||||
|
generator_ids.add(generator_id)
|
||||||
|
cachefile_registry.register(generator_id,
|
||||||
|
SourceGroupFilesGenerator(source_group, generator_id))
|
||||||
|
|
||||||
|
def unregister(self, generator_id, source_group):
|
||||||
|
from .specs.sourcegroups import SourceGroupFilesGenerator
|
||||||
|
generator_ids = self._source_groups.setdefault(source_group, set())
|
||||||
|
if generator_id in generator_ids:
|
||||||
|
generator_ids.remove(generator_id)
|
||||||
|
cachefile_registry.unregister(generator_id,
|
||||||
|
SourceGroupFilesGenerator(source_group, generator_id))
|
||||||
|
|
||||||
|
def source_group_receiver(self, sender, source, signal, **kwargs):
|
||||||
|
"""
|
||||||
|
Relay source group signals to the appropriate spec strategy.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from .cachefiles import ImageCacheFile
|
||||||
|
source_group = sender
|
||||||
|
|
||||||
|
# Ignore signals from unregistered groups.
|
||||||
|
if source_group not in self._source_groups:
|
||||||
|
return
|
||||||
|
|
||||||
|
specs = [generator_registry.get(id, source=source) for id in
|
||||||
|
self._source_groups[source_group]]
|
||||||
|
callback_name = self._signals[signal]
|
||||||
|
|
||||||
|
for spec in specs:
|
||||||
|
file = ImageCacheFile(spec)
|
||||||
|
call_strategy_method(file, callback_name)
|
||||||
|
|
||||||
|
|
||||||
|
class CacheFileRegistry(object):
|
||||||
|
"""
|
||||||
|
An object for registering generated files with image generators. The two are
|
||||||
|
associated with each other via a string id. We do this (as opposed to
|
||||||
|
associating them directly by, for example, putting a ``cachefiles``
|
||||||
|
attribute on image generators) so that image generators can be overridden
|
||||||
|
without losing the associated files. That way, a distributable app can
|
||||||
|
define its own generators without locking the users of the app into it.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cachefiles = {}
|
||||||
|
|
||||||
|
def register(self, generator_id, cachefiles):
|
||||||
|
"""
|
||||||
|
Associates generated files with a generator id
|
||||||
|
|
||||||
|
"""
|
||||||
|
if cachefiles not in self._cachefiles:
|
||||||
|
self._cachefiles[cachefiles] = set()
|
||||||
|
self._cachefiles[cachefiles].add(generator_id)
|
||||||
|
|
||||||
|
def unregister(self, generator_id, cachefiles):
|
||||||
|
"""
|
||||||
|
Disassociates generated files with a generator id
|
||||||
|
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._cachefiles[cachefiles].remove(generator_id)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, generator_id):
|
||||||
|
for k, v in self._cachefiles.items():
|
||||||
|
if generator_id in v:
|
||||||
|
for file in k():
|
||||||
|
yield file
|
||||||
|
|
||||||
|
|
||||||
|
class Register(object):
|
||||||
|
"""
|
||||||
|
Register generators and generated files.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def generator(self, id, generator=None):
|
||||||
|
if generator is None:
|
||||||
|
# Return a decorator
|
||||||
|
def decorator(cls):
|
||||||
|
self.generator(id, cls)
|
||||||
|
return cls
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
generator_registry.register(id, generator)
|
||||||
|
|
||||||
|
# iterable that returns kwargs or callable that returns iterable of kwargs
|
||||||
|
def cachefiles(self, generator_id, cachefiles):
|
||||||
|
cachefile_registry.register(generator_id, cachefiles)
|
||||||
|
|
||||||
|
def source_group(self, generator_id, source_group):
|
||||||
|
source_group_registry.register(generator_id, source_group)
|
||||||
|
|
||||||
|
|
||||||
|
class Unregister(object):
|
||||||
|
"""
|
||||||
|
Unregister generators and generated files.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def generator(self, id):
|
||||||
|
generator_registry.unregister(id)
|
||||||
|
|
||||||
|
def cachefiles(self, generator_id, cachefiles):
|
||||||
|
cachefile_registry.unregister(generator_id, cachefiles)
|
||||||
|
|
||||||
|
def source_group(self, generator_id, source_group):
|
||||||
|
source_group_registry.unregister(generator_id, source_group)
|
||||||
|
|
||||||
|
|
||||||
|
generator_registry = GeneratorRegistry()
|
||||||
|
cachefile_registry = CacheFileRegistry()
|
||||||
|
source_group_registry = SourceGroupRegistry()
|
||||||
|
register = Register()
|
||||||
|
unregister = Unregister()
|
||||||
9
imagekit/signals.py
Normal file
9
imagekit/signals.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from django.dispatch import Signal
|
||||||
|
|
||||||
|
|
||||||
|
# Generated file signals
|
||||||
|
content_required = Signal()
|
||||||
|
existence_required = Signal()
|
||||||
|
|
||||||
|
# Source group signals
|
||||||
|
source_saved = Signal()
|
||||||
250
imagekit/specs/__init__.py
Normal file
250
imagekit/specs/__init__.py
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
from copy import copy
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models.fields.files import ImageFieldFile
|
||||||
|
from ..cachefiles.backends import get_default_cachefile_backend
|
||||||
|
from ..cachefiles.strategies import load_strategy
|
||||||
|
from .. import hashers
|
||||||
|
from ..exceptions import AlreadyRegistered, MissingSource
|
||||||
|
from ..utils import open_image, get_by_qname, process_image
|
||||||
|
from ..registry import generator_registry, register
|
||||||
|
|
||||||
|
|
||||||
|
class BaseImageSpec(object):
|
||||||
|
"""
|
||||||
|
An object that defines how an new image should be generated from a source
|
||||||
|
image.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
cachefile_storage = None
|
||||||
|
"""A Django storage system to use to save a cache file."""
|
||||||
|
|
||||||
|
cachefile_backend = None
|
||||||
|
"""
|
||||||
|
An object responsible for managing the state of cache files. Defaults to
|
||||||
|
an instance of ``IMAGEKIT_DEFAULT_CACHEFILE_BACKEND``
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
cachefile_strategy = settings.IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY
|
||||||
|
"""
|
||||||
|
A dictionary containing callbacks that allow you to customize how and when
|
||||||
|
the image file is created. Defaults to
|
||||||
|
``IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cachefile_backend = self.cachefile_backend or get_default_cachefile_backend()
|
||||||
|
self.cachefile_strategy = load_strategy(self.cachefile_strategy)
|
||||||
|
|
||||||
|
def generate(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
MissingSource = MissingSource
|
||||||
|
"""
|
||||||
|
Raised when an operation requiring a source is attempted on a spec that has
|
||||||
|
no source.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ImageSpec(BaseImageSpec):
|
||||||
|
"""
|
||||||
|
An object that defines how to generate a new image from a source file using
|
||||||
|
PIL-based processors. (See :mod:`imagekit.processors`)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
processors = []
|
||||||
|
"""A list of processors to run on the original image."""
|
||||||
|
|
||||||
|
format = None
|
||||||
|
"""
|
||||||
|
The format of the output file. If not provided, ImageSpecField will try to
|
||||||
|
guess the appropriate format based on the extension of the filename and the
|
||||||
|
format of the input image.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
options = None
|
||||||
|
"""
|
||||||
|
A dictionary that will be passed to PIL's ``Image.save()`` method as keyword
|
||||||
|
arguments. Valid options vary between formats, but some examples include
|
||||||
|
``quality``, ``optimize``, and ``progressive`` for JPEGs. See the PIL
|
||||||
|
documentation for others.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
autoconvert = True
|
||||||
|
"""
|
||||||
|
Specifies whether automatic conversion using ``prepare_image()`` should be
|
||||||
|
performed prior to saving.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, source):
|
||||||
|
self.source = source
|
||||||
|
super(ImageSpec, self).__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cachefile_name(self):
|
||||||
|
if not self.source:
|
||||||
|
return None
|
||||||
|
fn = get_by_qname(settings.IMAGEKIT_SPEC_CACHEFILE_NAMER, 'namer')
|
||||||
|
return fn(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self):
|
||||||
|
src = getattr(self, '_source', None)
|
||||||
|
if not src:
|
||||||
|
field_data = getattr(self, '_field_data', None)
|
||||||
|
if field_data:
|
||||||
|
src = self._source = getattr(field_data['instance'], field_data['attname'])
|
||||||
|
del self._field_data
|
||||||
|
return src
|
||||||
|
|
||||||
|
@source.setter
|
||||||
|
def source(self, value):
|
||||||
|
self._source = value
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
state = copy(self.__dict__)
|
||||||
|
|
||||||
|
# Unpickled ImageFieldFiles won't work (they're missing a storage
|
||||||
|
# object). Since they're such a common use case, we special case them.
|
||||||
|
# Unfortunately, this also requires us to add the source getter to
|
||||||
|
# lazily retrieve the source on the reconstructed object; simply trying
|
||||||
|
# to look up the source in ``__setstate__`` would require us to get the
|
||||||
|
# model instance but, if ``__setstate__`` was called as part of
|
||||||
|
# deserializing that model, the model wouldn't be fully reconstructed
|
||||||
|
# yet, preventing us from accessing the source field.
|
||||||
|
# (This is issue #234.)
|
||||||
|
if isinstance(self.source, ImageFieldFile):
|
||||||
|
field = getattr(self.source, 'field')
|
||||||
|
state['_field_data'] = {
|
||||||
|
'instance': getattr(self.source, 'instance', None),
|
||||||
|
'attname': getattr(field, 'name', None),
|
||||||
|
}
|
||||||
|
state.pop('_source', None)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def get_hash(self):
|
||||||
|
return hashers.pickle([
|
||||||
|
self.source.name,
|
||||||
|
self.processors,
|
||||||
|
self.format,
|
||||||
|
self.options,
|
||||||
|
self.autoconvert,
|
||||||
|
])
|
||||||
|
|
||||||
|
def generate(self):
|
||||||
|
if not self.source:
|
||||||
|
raise MissingSource("The spec '%s' has no source file associated"
|
||||||
|
" with it." % self)
|
||||||
|
|
||||||
|
# TODO: Move into a generator base class
|
||||||
|
# TODO: Factor out a generate_image function so you can create a generator and only override the PIL.Image creating part. (The tricky part is how to deal with original_format since generator base class won't have one.)
|
||||||
|
|
||||||
|
closed = self.source.closed
|
||||||
|
if closed:
|
||||||
|
# Django file object should know how to reopen itself if it was closed
|
||||||
|
# https://code.djangoproject.com/ticket/13750
|
||||||
|
self.source.open()
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = open_image(self.source)
|
||||||
|
new_image = process_image(img,
|
||||||
|
processors=self.processors,
|
||||||
|
format=self.format,
|
||||||
|
autoconvert=self.autoconvert,
|
||||||
|
options=self.options)
|
||||||
|
finally:
|
||||||
|
if closed:
|
||||||
|
# We need to close the file if it was opened by us
|
||||||
|
self.source.close()
|
||||||
|
return new_image
|
||||||
|
|
||||||
|
|
||||||
|
def create_spec_class(class_attrs):
|
||||||
|
|
||||||
|
class DynamicSpecBase(ImageSpec):
|
||||||
|
def __reduce__(self):
|
||||||
|
try:
|
||||||
|
getstate = self.__getstate__
|
||||||
|
except AttributeError:
|
||||||
|
state = self.__dict__
|
||||||
|
else:
|
||||||
|
state = getstate()
|
||||||
|
return (create_spec, (class_attrs, state))
|
||||||
|
|
||||||
|
return type('DynamicSpec', (DynamicSpecBase,), class_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def create_spec(class_attrs, state):
|
||||||
|
cls = create_spec_class(class_attrs)
|
||||||
|
instance = cls.__new__(cls) # Create an instance without calling the __init__ (which may have required args).
|
||||||
|
try:
|
||||||
|
setstate = instance.__setstate__
|
||||||
|
except AttributeError:
|
||||||
|
instance.__dict__ = state
|
||||||
|
else:
|
||||||
|
setstate(state)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class SpecHost(object):
|
||||||
|
"""
|
||||||
|
An object that ostensibly has a spec attribute but really delegates to the
|
||||||
|
spec registry.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self, spec=None, spec_id=None, **kwargs):
|
||||||
|
|
||||||
|
spec_attrs = dict((k, v) for k, v in kwargs.items() if v is not None)
|
||||||
|
|
||||||
|
if spec_attrs:
|
||||||
|
if spec:
|
||||||
|
raise TypeError('You can provide either an image spec or'
|
||||||
|
' arguments for the ImageSpec constructor, but not both.')
|
||||||
|
else:
|
||||||
|
spec = create_spec_class(spec_attrs)
|
||||||
|
|
||||||
|
self._original_spec = spec
|
||||||
|
|
||||||
|
if spec_id:
|
||||||
|
self.set_spec_id(spec_id)
|
||||||
|
|
||||||
|
def set_spec_id(self, id):
|
||||||
|
"""
|
||||||
|
Sets the spec id for this object. Useful for when the id isn't
|
||||||
|
known when the instance is constructed (e.g. for ImageSpecFields whose
|
||||||
|
generated `spec_id`s are only known when they are contributed to a
|
||||||
|
class). If the object was initialized with a spec, it will be registered
|
||||||
|
under the provided id.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.spec_id = id
|
||||||
|
|
||||||
|
if self._original_spec:
|
||||||
|
try:
|
||||||
|
register.generator(id, self._original_spec)
|
||||||
|
except AlreadyRegistered:
|
||||||
|
# Fields should not cause AlreadyRegistered exceptions. If a
|
||||||
|
# spec is already registered, that should be used. It is
|
||||||
|
# especially important that an error is not thrown here because
|
||||||
|
# of South, which will create duplicate models as part of its
|
||||||
|
# "fake orm," therefore re-registering specs.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_spec(self, source):
|
||||||
|
"""
|
||||||
|
Look up the spec by the spec id. We do this (instead of storing the
|
||||||
|
spec as an attribute) so that users can override apps' specs--without
|
||||||
|
having to edit model definitions--simply by registering another spec
|
||||||
|
with the same id.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not getattr(self, 'spec_id', None):
|
||||||
|
raise Exception('Object %s has no spec id.' % self)
|
||||||
|
return generator_registry.get(self.spec_id, source=source)
|
||||||
175
imagekit/specs/sourcegroups.py
Normal file
175
imagekit/specs/sourcegroups.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""
|
||||||
|
Source groups are the means by which image spec sources are identified. They
|
||||||
|
have two responsibilities:
|
||||||
|
|
||||||
|
1. To dispatch ``source_saved`` signals. (These will be relayed to the
|
||||||
|
corresponding specs' cache file strategies.)
|
||||||
|
2. To provide the source files that they represent, via a generator method named
|
||||||
|
``files()``. (This is used by the generateimages management command for
|
||||||
|
"pre-caching" image files.)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db.models.signals import post_init, post_save
|
||||||
|
from django.utils.functional import wraps
|
||||||
|
import inspect
|
||||||
|
from ..cachefiles import LazyImageCacheFile
|
||||||
|
from ..signals import source_saved
|
||||||
|
from ..utils import get_nonabstract_descendants
|
||||||
|
|
||||||
|
|
||||||
|
def ik_model_receiver(fn):
|
||||||
|
"""
|
||||||
|
A method decorator that filters out signals coming from models that don't
|
||||||
|
have fields that function as ImageFieldSourceGroup sources.
|
||||||
|
|
||||||
|
"""
|
||||||
|
@wraps(fn)
|
||||||
|
def receiver(self, sender, **kwargs):
|
||||||
|
if not inspect.isclass(sender):
|
||||||
|
return
|
||||||
|
for src in self._source_groups:
|
||||||
|
if issubclass(sender, src.model_class):
|
||||||
|
fn(self, sender=sender, **kwargs)
|
||||||
|
|
||||||
|
# If we find a match, return. We don't want to handle the signal
|
||||||
|
# more than once.
|
||||||
|
return
|
||||||
|
return receiver
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSignalRouter(object):
|
||||||
|
"""
|
||||||
|
Normally, ``ImageFieldSourceGroup`` would be directly responsible for
|
||||||
|
watching for changes on the model field it represents. However, Django does
|
||||||
|
not dispatch events for abstract base classes. Therefore, we must listen for
|
||||||
|
the signals on all models and filter out those that aren't represented by
|
||||||
|
``ImageFieldSourceGroup``s. This class encapsulates that functionality.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
https://github.com/matthewwithanm/django-imagekit/issues/126
|
||||||
|
https://code.djangoproject.com/ticket/9318
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._source_groups = []
|
||||||
|
uid = 'ik_spec_field_receivers'
|
||||||
|
post_init.connect(self.post_init_receiver, dispatch_uid=uid)
|
||||||
|
post_save.connect(self.post_save_receiver, dispatch_uid=uid)
|
||||||
|
|
||||||
|
def add(self, source_group):
|
||||||
|
self._source_groups.append(source_group)
|
||||||
|
|
||||||
|
def init_instance(self, instance):
|
||||||
|
instance._ik = getattr(instance, '_ik', {})
|
||||||
|
|
||||||
|
def update_source_hashes(self, instance):
|
||||||
|
"""
|
||||||
|
Stores hashes of the source image files so that they can be compared
|
||||||
|
later to see whether the source image has changed (and therefore whether
|
||||||
|
the spec file needs to be regenerated).
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.init_instance(instance)
|
||||||
|
instance._ik['source_hashes'] = dict(
|
||||||
|
(attname, hash(getattr(instance, attname)))
|
||||||
|
for attname in self.get_source_fields(instance))
|
||||||
|
return instance._ik['source_hashes']
|
||||||
|
|
||||||
|
def get_source_fields(self, instance):
|
||||||
|
"""
|
||||||
|
Returns a list of the source fields for the given instance.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return set(src.image_field
|
||||||
|
for src in self._source_groups
|
||||||
|
if isinstance(instance, src.model_class))
|
||||||
|
|
||||||
|
@ik_model_receiver
|
||||||
|
def post_save_receiver(self, sender, instance=None, created=False, update_fields=None, raw=False, **kwargs):
|
||||||
|
if not raw:
|
||||||
|
self.init_instance(instance)
|
||||||
|
old_hashes = instance._ik.get('source_hashes', {}).copy()
|
||||||
|
new_hashes = self.update_source_hashes(instance)
|
||||||
|
for attname in self.get_source_fields(instance):
|
||||||
|
if update_fields and attname not in update_fields:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file = getattr(instance, attname)
|
||||||
|
if file and old_hashes.get(attname) != new_hashes[attname]:
|
||||||
|
self.dispatch_signal(source_saved, file, sender, instance,
|
||||||
|
attname)
|
||||||
|
|
||||||
|
@ik_model_receiver
|
||||||
|
def post_init_receiver(self, sender, instance=None, **kwargs):
|
||||||
|
self.init_instance(instance)
|
||||||
|
source_fields = self.get_source_fields(instance)
|
||||||
|
local_fields = dict((field.name, field)
|
||||||
|
for field in instance._meta.local_fields
|
||||||
|
if field.name in source_fields)
|
||||||
|
instance._ik['source_hashes'] = dict(
|
||||||
|
(attname, hash(file_field))
|
||||||
|
for attname, file_field in local_fields.items())
|
||||||
|
|
||||||
|
def dispatch_signal(self, signal, file, model_class, instance, attname):
|
||||||
|
"""
|
||||||
|
Dispatch the signal for each of the matching source groups. Note that
|
||||||
|
more than one source can have the same model and image_field; it's
|
||||||
|
important that we dispatch the signal for each.
|
||||||
|
|
||||||
|
"""
|
||||||
|
for source_group in self._source_groups:
|
||||||
|
if issubclass(model_class, source_group.model_class) and source_group.image_field == attname:
|
||||||
|
signal.send(sender=source_group, source=file)
|
||||||
|
|
||||||
|
|
||||||
|
class ImageFieldSourceGroup(object):
|
||||||
|
"""
|
||||||
|
A source group that repesents a particular field across all instances of a
|
||||||
|
model and its subclasses.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self, model_class, image_field):
|
||||||
|
self.model_class = model_class
|
||||||
|
self.image_field = image_field
|
||||||
|
signal_router.add(self)
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
"""
|
||||||
|
A generator that returns the source files that this source group
|
||||||
|
represents; in this case, a particular field of every instance of a
|
||||||
|
particular model and its subclasses.
|
||||||
|
|
||||||
|
"""
|
||||||
|
for model in get_nonabstract_descendants(self.model_class):
|
||||||
|
for instance in model.objects.all().iterator():
|
||||||
|
yield getattr(instance, self.image_field)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceGroupFilesGenerator(object):
|
||||||
|
"""
|
||||||
|
A Python generator that yields cache file objects for source groups.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self, source_group, generator_id):
|
||||||
|
self.source_group = source_group
|
||||||
|
self.generator_id = generator_id
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (isinstance(other, self.__class__)
|
||||||
|
and self.__dict__ == other.__dict__)
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.source_group, self.generator_id))
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
for source_file in self.source_group.files():
|
||||||
|
yield LazyImageCacheFile(self.generator_id,
|
||||||
|
source=source_file)
|
||||||
|
|
||||||
|
|
||||||
|
signal_router = ModelSignalRouter()
|
||||||
285
imagekit/templatetags/imagekit.py
Normal file
285
imagekit/templatetags/imagekit.py
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from ..compat import parse_bits
|
||||||
|
from ..cachefiles import ImageCacheFile
|
||||||
|
from ..registry import generator_registry
|
||||||
|
from ..lib import force_text
|
||||||
|
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
ASSIGNMENT_DELIMETER = 'as'
|
||||||
|
HTML_ATTRS_DELIMITER = '--'
|
||||||
|
DEFAULT_THUMBNAIL_GENERATOR = 'imagekit:thumbnail'
|
||||||
|
|
||||||
|
|
||||||
|
def get_cachefile(context, generator_id, generator_kwargs, source=None):
|
||||||
|
generator_id = generator_id.resolve(context)
|
||||||
|
kwargs = dict((k, v.resolve(context)) for k, v in generator_kwargs.items())
|
||||||
|
generator = generator_registry.get(generator_id, **kwargs)
|
||||||
|
return ImageCacheFile(generator)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dimensions(dimensions):
|
||||||
|
"""
|
||||||
|
Parse the width and height values from a dimension string. Valid values are
|
||||||
|
'1x1', '1x', and 'x1'. If one of the dimensions is omitted, the parse result
|
||||||
|
will be None for that value.
|
||||||
|
|
||||||
|
"""
|
||||||
|
width, height = [d.strip() and int(d) or None for d in dimensions.split('x')]
|
||||||
|
return dict(width=width, height=height)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateImageAssignmentNode(template.Node):
|
||||||
|
|
||||||
|
def __init__(self, variable_name, generator_id, generator_kwargs):
|
||||||
|
self._generator_id = generator_id
|
||||||
|
self._generator_kwargs = generator_kwargs
|
||||||
|
self._variable_name = variable_name
|
||||||
|
|
||||||
|
def get_variable_name(self, context):
|
||||||
|
return force_text(self._variable_name)
|
||||||
|
|
||||||
|
def render(self, context):
|
||||||
|
variable_name = self.get_variable_name(context)
|
||||||
|
context[variable_name] = get_cachefile(context, self._generator_id,
|
||||||
|
self._generator_kwargs)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateImageTagNode(template.Node):
|
||||||
|
|
||||||
|
def __init__(self, generator_id, generator_kwargs, html_attrs):
|
||||||
|
self._generator_id = generator_id
|
||||||
|
self._generator_kwargs = generator_kwargs
|
||||||
|
self._html_attrs = html_attrs
|
||||||
|
|
||||||
|
def render(self, context):
|
||||||
|
file = get_cachefile(context, self._generator_id,
|
||||||
|
self._generator_kwargs)
|
||||||
|
attrs = dict((k, v.resolve(context)) for k, v in
|
||||||
|
self._html_attrs.items())
|
||||||
|
|
||||||
|
# Only add width and height if neither is specified (to allow for
|
||||||
|
# proportional in-browser scaling).
|
||||||
|
if not 'width' in attrs and not 'height' in attrs:
|
||||||
|
attrs.update(width=file.width, height=file.height)
|
||||||
|
|
||||||
|
attrs['src'] = file.url
|
||||||
|
attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in
|
||||||
|
attrs.items())
|
||||||
|
return mark_safe('<img %s />' % attr_str)
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailAssignmentNode(template.Node):
|
||||||
|
|
||||||
|
def __init__(self, variable_name, generator_id, dimensions, source, generator_kwargs):
|
||||||
|
self._variable_name = variable_name
|
||||||
|
self._generator_id = generator_id
|
||||||
|
self._dimensions = dimensions
|
||||||
|
self._source = source
|
||||||
|
self._generator_kwargs = generator_kwargs
|
||||||
|
|
||||||
|
def get_variable_name(self, context):
|
||||||
|
return force_text(self._variable_name)
|
||||||
|
|
||||||
|
def render(self, context):
|
||||||
|
variable_name = self.get_variable_name(context)
|
||||||
|
|
||||||
|
generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR
|
||||||
|
kwargs = dict((k, v.resolve(context)) for k, v in
|
||||||
|
self._generator_kwargs.items())
|
||||||
|
kwargs['source'] = self._source.resolve(context)
|
||||||
|
kwargs.update(parse_dimensions(self._dimensions.resolve(context)))
|
||||||
|
generator = generator_registry.get(generator_id, **kwargs)
|
||||||
|
|
||||||
|
context[variable_name] = ImageCacheFile(generator)
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
class ThumbnailImageTagNode(template.Node):
|
||||||
|
|
||||||
|
def __init__(self, generator_id, dimensions, source, generator_kwargs, html_attrs):
|
||||||
|
self._generator_id = generator_id
|
||||||
|
self._dimensions = dimensions
|
||||||
|
self._source = source
|
||||||
|
self._generator_kwargs = generator_kwargs
|
||||||
|
self._html_attrs = html_attrs
|
||||||
|
|
||||||
|
def render(self, context):
|
||||||
|
generator_id = self._generator_id.resolve(context) if self._generator_id else DEFAULT_THUMBNAIL_GENERATOR
|
||||||
|
dimensions = parse_dimensions(self._dimensions.resolve(context))
|
||||||
|
kwargs = dict((k, v.resolve(context)) for k, v in
|
||||||
|
self._generator_kwargs.items())
|
||||||
|
kwargs['source'] = self._source.resolve(context)
|
||||||
|
kwargs.update(dimensions)
|
||||||
|
generator = generator_registry.get(generator_id, **kwargs)
|
||||||
|
|
||||||
|
file = ImageCacheFile(generator)
|
||||||
|
|
||||||
|
attrs = dict((k, v.resolve(context)) for k, v in
|
||||||
|
self._html_attrs.items())
|
||||||
|
|
||||||
|
# Only add width and height if neither is specified (to allow for
|
||||||
|
# proportional in-browser scaling).
|
||||||
|
if not 'width' in attrs and not 'height' in attrs:
|
||||||
|
attrs.update(width=file.width, height=file.height)
|
||||||
|
|
||||||
|
attrs['src'] = file.url
|
||||||
|
attr_str = ' '.join('%s="%s"' % (escape(k), escape(v)) for k, v in
|
||||||
|
attrs.items())
|
||||||
|
return mark_safe('<img %s />' % attr_str)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ik_tag_bits(parser, bits):
|
||||||
|
"""
|
||||||
|
Parses the tag name, html attributes and variable name (for assignment tags)
|
||||||
|
from the provided bits. The preceding bits may vary and are left to be
|
||||||
|
parsed by specific tags.
|
||||||
|
|
||||||
|
"""
|
||||||
|
varname = None
|
||||||
|
html_attrs = {}
|
||||||
|
tag_name = bits.pop(0)
|
||||||
|
|
||||||
|
if len(bits) >= 2 and bits[-2] == ASSIGNMENT_DELIMETER:
|
||||||
|
varname = bits[-1]
|
||||||
|
bits = bits[:-2]
|
||||||
|
|
||||||
|
if HTML_ATTRS_DELIMITER in bits:
|
||||||
|
|
||||||
|
if varname:
|
||||||
|
raise template.TemplateSyntaxError('Do not specify html attributes'
|
||||||
|
' (using "%s") when using the "%s" tag as an assignment'
|
||||||
|
' tag.' % (HTML_ATTRS_DELIMITER, tag_name))
|
||||||
|
|
||||||
|
index = bits.index(HTML_ATTRS_DELIMITER)
|
||||||
|
html_bits = bits[index + 1:]
|
||||||
|
bits = bits[:index]
|
||||||
|
|
||||||
|
if not html_bits:
|
||||||
|
raise template.TemplateSyntaxError('Don\'t use "%s" unless you\'re'
|
||||||
|
' setting html attributes.' % HTML_ATTRS_DELIMITER)
|
||||||
|
|
||||||
|
args, html_attrs = parse_bits(parser, html_bits, [], 'args',
|
||||||
|
'kwargs', None, False, tag_name)
|
||||||
|
if len(args):
|
||||||
|
raise template.TemplateSyntaxError('All "%s" tag arguments after'
|
||||||
|
' the "%s" token must be named.' % (tag_name,
|
||||||
|
HTML_ATTRS_DELIMITER))
|
||||||
|
|
||||||
|
return (tag_name, bits, html_attrs, varname)
|
||||||
|
|
||||||
|
|
||||||
|
#@register.tag
|
||||||
|
def generateimage(parser, token):
|
||||||
|
"""
|
||||||
|
Creates an image based on the provided arguments.
|
||||||
|
|
||||||
|
By default::
|
||||||
|
|
||||||
|
{% generateimage 'myapp:thumbnail' source=mymodel.profile_image %}
|
||||||
|
|
||||||
|
generates an ``<img>`` tag::
|
||||||
|
|
||||||
|
<img src="/path/to/34d944f200dd794bf1e6a7f37849f72b.jpg" width="100" height="100" />
|
||||||
|
|
||||||
|
You can add additional attributes to the tag using "--". For example,
|
||||||
|
this::
|
||||||
|
|
||||||
|
{% generateimage 'myapp:thumbnail' source=mymodel.profile_image -- alt="Hello!" %}
|
||||||
|
|
||||||
|
will result in the following markup::
|
||||||
|
|
||||||
|
<img src="/path/to/34d944f200dd794bf1e6a7f37849f72b.jpg" width="100" height="100" alt="Hello!" />
|
||||||
|
|
||||||
|
For more flexibility, ``generateimage`` also works as an assignment tag::
|
||||||
|
|
||||||
|
{% generateimage 'myapp:thumbnail' source=mymodel.profile_image as th %}
|
||||||
|
<img src="{{ th.url }}" width="{{ th.width }}" height="{{ th.height }}" />
|
||||||
|
|
||||||
|
"""
|
||||||
|
bits = token.split_contents()
|
||||||
|
|
||||||
|
tag_name, bits, html_attrs, varname = parse_ik_tag_bits(parser, bits)
|
||||||
|
|
||||||
|
args, kwargs = parse_bits(parser, bits, ['generator_id'], 'args', 'kwargs',
|
||||||
|
None, False, tag_name)
|
||||||
|
|
||||||
|
if len(args) != 1:
|
||||||
|
raise template.TemplateSyntaxError('The "%s" tag requires exactly one'
|
||||||
|
' unnamed argument (the generator id).' % tag_name)
|
||||||
|
|
||||||
|
generator_id = args[0]
|
||||||
|
|
||||||
|
if varname:
|
||||||
|
return GenerateImageAssignmentNode(varname, generator_id, kwargs)
|
||||||
|
else:
|
||||||
|
return GenerateImageTagNode(generator_id, kwargs, html_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
#@register.tag
|
||||||
|
def thumbnail(parser, token):
|
||||||
|
"""
|
||||||
|
A convenient shortcut syntax for generating a thumbnail. The following::
|
||||||
|
|
||||||
|
{% thumbnail '100x100' mymodel.profile_image %}
|
||||||
|
|
||||||
|
is equivalent to::
|
||||||
|
|
||||||
|
{% generateimage 'imagekit:thumbnail' source=mymodel.profile_image width=100 height=100 %}
|
||||||
|
|
||||||
|
The thumbnail tag supports the "--" and "as" bits for adding html
|
||||||
|
attributes and assigning to a variable, respectively. It also accepts the
|
||||||
|
kwargs "anchor", and "crop".
|
||||||
|
|
||||||
|
To use "smart cropping" (the ``SmartResize`` processor)::
|
||||||
|
|
||||||
|
{% thumbnail '100x100' mymodel.profile_image %}
|
||||||
|
|
||||||
|
To crop, anchoring the image to the top right (the ``ResizeToFill``
|
||||||
|
processor)::
|
||||||
|
|
||||||
|
{% thumbnail '100x100' mymodel.profile_image anchor='tr' %}
|
||||||
|
|
||||||
|
To resize without cropping (using the ``ResizeToFit`` processor)::
|
||||||
|
|
||||||
|
{% thumbnail '100x100' mymodel.profile_image crop=0 %}
|
||||||
|
|
||||||
|
"""
|
||||||
|
bits = token.split_contents()
|
||||||
|
|
||||||
|
tag_name, bits, html_attrs, varname = parse_ik_tag_bits(parser, bits)
|
||||||
|
|
||||||
|
args, kwargs = parse_bits(parser, bits, [], 'args', 'kwargs',
|
||||||
|
None, False, tag_name)
|
||||||
|
|
||||||
|
if len(args) < 2:
|
||||||
|
raise template.TemplateSyntaxError('The "%s" tag requires at least two'
|
||||||
|
' unnamed arguments: the dimensions and the source image.'
|
||||||
|
% tag_name)
|
||||||
|
elif len(args) > 3:
|
||||||
|
raise template.TemplateSyntaxError('The "%s" tag accepts at most three'
|
||||||
|
' unnamed arguments: a generator id, the dimensions, and the'
|
||||||
|
' source image.' % tag_name)
|
||||||
|
|
||||||
|
dimensions, source = args[-2:]
|
||||||
|
generator_id = args[0] if len(args) > 2 else None
|
||||||
|
|
||||||
|
if varname:
|
||||||
|
return ThumbnailAssignmentNode(varname, generator_id, dimensions,
|
||||||
|
source, kwargs)
|
||||||
|
else:
|
||||||
|
return ThumbnailImageTagNode(generator_id, dimensions, source, kwargs,
|
||||||
|
html_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
generateimage = register.tag(generateimage)
|
||||||
|
thumbnail = register.tag(thumbnail)
|
||||||
|
|
@ -1,381 +1,191 @@
|
||||||
import os
|
from __future__ import unicode_literals
|
||||||
import mimetypes
|
import logging
|
||||||
import sys
|
import re
|
||||||
import types
|
from tempfile import NamedTemporaryFile
|
||||||
|
from hashlib import md5
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
from django.conf import settings
|
||||||
from django.db.models.loading import cache
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.utils.functional import wraps
|
from django.core.files import File
|
||||||
from django.utils.encoding import smart_str, smart_unicode
|
try:
|
||||||
|
from importlib import import_module
|
||||||
from .lib import Image, ImageFile, StringIO
|
except ImportError:
|
||||||
|
from django.utils.importlib import import_module
|
||||||
|
from pilkit.utils import *
|
||||||
|
from .lib import NullHandler, force_bytes
|
||||||
|
|
||||||
|
|
||||||
RGBA_TRANSPARENCY_FORMATS = ['PNG']
|
bad_memcached_key_chars = re.compile('[\u0000-\u001f\\s]+')
|
||||||
PALETTE_TRANSPARENCY_FORMATS = ['PNG', 'GIF']
|
|
||||||
|
_autodiscovered = False
|
||||||
|
|
||||||
|
def get_nonabstract_descendants(model):
|
||||||
|
""" Returns all non-abstract descendants of the model. """
|
||||||
|
if not model._meta.abstract:
|
||||||
|
yield model
|
||||||
|
for s in model.__subclasses__():
|
||||||
|
for m in get_nonabstract_descendants(s):
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
class IKContentFile(ContentFile):
|
def get_by_qname(path, desc):
|
||||||
"""
|
|
||||||
Wraps a ContentFile in a file-like object with a filename and a
|
|
||||||
content_type. A PIL image format can be optionally be provided as a content
|
|
||||||
type hint.
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self, filename, content, format=None):
|
|
||||||
self.file = ContentFile(content)
|
|
||||||
self.file.name = filename
|
|
||||||
mimetype = getattr(self.file, 'content_type', None)
|
|
||||||
if format and not mimetype:
|
|
||||||
mimetype = format_to_mimetype(format)
|
|
||||||
if not mimetype:
|
|
||||||
ext = os.path.splitext(filename or '')[1]
|
|
||||||
mimetype = extension_to_mimetype(ext)
|
|
||||||
self.file.content_type = mimetype
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return smart_str(self.file.name or '')
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return smart_unicode(self.file.name or u'')
|
|
||||||
|
|
||||||
|
|
||||||
def img_to_fobj(img, format, autoconvert=True, **options):
|
|
||||||
return save_image(img, StringIO(), format, options, autoconvert)
|
|
||||||
|
|
||||||
|
|
||||||
def get_spec_files(instance):
|
|
||||||
try:
|
try:
|
||||||
return instance._ik.spec_files
|
dot = path.rindex('.')
|
||||||
|
except ValueError:
|
||||||
|
raise ImproperlyConfigured("%s isn't a %s module." % (path, desc))
|
||||||
|
module, objname = path[:dot], path[dot + 1:]
|
||||||
|
try:
|
||||||
|
mod = import_module(module)
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImproperlyConfigured('Error importing %s module %s: "%s"' %
|
||||||
|
(desc, module, e))
|
||||||
|
try:
|
||||||
|
obj = getattr(mod, objname)
|
||||||
|
return obj
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return []
|
raise ImproperlyConfigured('%s module "%s" does not define "%s"'
|
||||||
|
% (desc[0].upper() + desc[1:], module, objname))
|
||||||
|
|
||||||
|
|
||||||
def open_image(target):
|
_singletons = {}
|
||||||
target.seek(0)
|
|
||||||
img = Image.open(target)
|
|
||||||
img.copy = types.MethodType(_wrap_copy(img.copy), img, img.__class__)
|
|
||||||
return img
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap_copy(f):
|
def get_singleton(class_path, desc):
|
||||||
@wraps(f)
|
global _singletons
|
||||||
def copy(self):
|
cls = get_by_qname(class_path, desc)
|
||||||
img = f()
|
instance = _singletons.get(cls)
|
||||||
try:
|
if not instance:
|
||||||
img.app = self.app
|
instance = _singletons[cls] = cls()
|
||||||
except AttributeError:
|
return instance
|
||||||
pass
|
|
||||||
try:
|
|
||||||
img._getexif = self._getexif
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
return img
|
|
||||||
return copy
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownExtensionError(Exception):
|
def autodiscover():
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownFormatError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
_pil_init = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _preinit_pil():
|
|
||||||
"""Loads the standard PIL file format drivers. Returns True if ``preinit()``
|
|
||||||
was called (and there's a potential that more drivers were loaded) or False
|
|
||||||
if there is no possibility that new drivers were loaded.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
global _pil_init
|
Auto-discover INSTALLED_APPS imagegenerators.py modules and fail silently
|
||||||
if _pil_init < 1:
|
when not present. This forces an import on them to register any admin bits
|
||||||
Image.preinit()
|
they may want.
|
||||||
_pil_init = 1
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _init_pil():
|
|
||||||
"""Loads all PIL file format drivers. Returns True if ``init()`` was called
|
|
||||||
(and there's a potential that more drivers were loaded) or False if there is
|
|
||||||
no possibility that new drivers were loaded.
|
|
||||||
|
|
||||||
|
Copied from django.contrib.admin
|
||||||
"""
|
"""
|
||||||
global _pil_init
|
global _autodiscovered
|
||||||
_preinit_pil()
|
|
||||||
if _pil_init < 2:
|
|
||||||
Image.init()
|
|
||||||
_pil_init = 2
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
if _autodiscovered:
|
||||||
|
return
|
||||||
|
|
||||||
def _extension_to_format(extension):
|
|
||||||
return Image.EXTENSION.get(extension.lower())
|
|
||||||
|
|
||||||
|
|
||||||
def _format_to_extension(format):
|
|
||||||
if format:
|
|
||||||
for k, v in Image.EXTENSION.iteritems():
|
|
||||||
if v == format.upper():
|
|
||||||
return k
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def extension_to_mimetype(ext):
|
|
||||||
try:
|
try:
|
||||||
filename = 'a%s' % (ext or '') # guess_type requires a full filename, not just an extension
|
from django.utils.module_loading import autodiscover_modules
|
||||||
mimetype = mimetypes.guess_type(filename)[0]
|
except ImportError:
|
||||||
except IndexError:
|
# Django<1.7
|
||||||
mimetype = None
|
_autodiscover_modules_fallback()
|
||||||
return mimetype
|
|
||||||
|
|
||||||
|
|
||||||
def format_to_mimetype(format):
|
|
||||||
return extension_to_mimetype(format_to_extension(format))
|
|
||||||
|
|
||||||
|
|
||||||
def extension_to_format(extension):
|
|
||||||
"""Returns the format that corresponds to the provided extension.
|
|
||||||
|
|
||||||
"""
|
|
||||||
format = _extension_to_format(extension)
|
|
||||||
if not format and _preinit_pil():
|
|
||||||
format = _extension_to_format(extension)
|
|
||||||
if not format and _init_pil():
|
|
||||||
format = _extension_to_format(extension)
|
|
||||||
if not format:
|
|
||||||
raise UnknownExtensionError(extension)
|
|
||||||
return format
|
|
||||||
|
|
||||||
|
|
||||||
def format_to_extension(format):
|
|
||||||
"""Returns the first extension that matches the provided format.
|
|
||||||
|
|
||||||
"""
|
|
||||||
extension = None
|
|
||||||
if format:
|
|
||||||
extension = _format_to_extension(format)
|
|
||||||
if not extension and _preinit_pil():
|
|
||||||
extension = _format_to_extension(format)
|
|
||||||
if not extension and _init_pil():
|
|
||||||
extension = _format_to_extension(format)
|
|
||||||
if not extension:
|
|
||||||
raise UnknownFormatError(format)
|
|
||||||
return extension
|
|
||||||
|
|
||||||
|
|
||||||
def _get_models(apps):
|
|
||||||
models = []
|
|
||||||
for app_label in apps or []:
|
|
||||||
app = cache.get_app(app_label)
|
|
||||||
models += [m for m in cache.get_models(app)]
|
|
||||||
return models
|
|
||||||
|
|
||||||
|
|
||||||
def invalidate_app_cache(apps):
|
|
||||||
for model in _get_models(apps):
|
|
||||||
print 'Invalidating cache for "%s.%s"' % (model._meta.app_label, model.__name__)
|
|
||||||
for obj in model._default_manager.order_by('-pk'):
|
|
||||||
for f in get_spec_files(obj):
|
|
||||||
f.invalidate()
|
|
||||||
|
|
||||||
|
|
||||||
def validate_app_cache(apps, force_revalidation=False):
|
|
||||||
for model in _get_models(apps):
|
|
||||||
for obj in model._default_manager.order_by('-pk'):
|
|
||||||
model_name = '%s.%s' % (model._meta.app_label, model.__name__)
|
|
||||||
if force_revalidation:
|
|
||||||
print 'Invalidating & validating cache for "%s"' % model_name
|
|
||||||
else:
|
|
||||||
print 'Validating cache for "%s"' % model_name
|
|
||||||
for f in get_spec_files(obj):
|
|
||||||
if force_revalidation:
|
|
||||||
f.invalidate()
|
|
||||||
f.validate()
|
|
||||||
|
|
||||||
|
|
||||||
def suggest_extension(name, format):
|
|
||||||
original_extension = os.path.splitext(name)[1]
|
|
||||||
try:
|
|
||||||
suggested_extension = format_to_extension(format)
|
|
||||||
except UnknownFormatError:
|
|
||||||
extension = original_extension
|
|
||||||
else:
|
else:
|
||||||
if suggested_extension.lower() == original_extension.lower():
|
autodiscover_modules('imagegenerators')
|
||||||
extension = original_extension
|
_autodiscovered = True
|
||||||
else:
|
|
||||||
try:
|
|
||||||
original_format = extension_to_format(original_extension)
|
|
||||||
except UnknownExtensionError:
|
|
||||||
extension = suggested_extension
|
|
||||||
else:
|
|
||||||
# If the formats match, give precedence to the original extension.
|
|
||||||
if format.lower() == original_format.lower():
|
|
||||||
extension = original_extension
|
|
||||||
else:
|
|
||||||
extension = suggested_extension
|
|
||||||
return extension
|
|
||||||
|
|
||||||
|
|
||||||
def save_image(img, outfile, format, options=None, autoconvert=True):
|
def _autodiscover_modules_fallback():
|
||||||
"""
|
"""
|
||||||
Wraps PIL's ``Image.save()`` method. There are two main benefits of using
|
Auto-discover INSTALLED_APPS imagegenerators.py modules and fail silently
|
||||||
this function over PIL's:
|
when not present. This forces an import on them to register any admin bits
|
||||||
|
they may want.
|
||||||
|
|
||||||
1. It gracefully handles the infamous "Suspension not allowed here" errors.
|
Copied from django.contrib.admin
|
||||||
2. It prepares the image for saving using ``prepare_image()``, which will do
|
|
||||||
some common-sense processing given the target format.
|
|
||||||
|
|
||||||
|
Used for Django versions < 1.7
|
||||||
"""
|
"""
|
||||||
options = options or {}
|
from django.conf import settings
|
||||||
|
|
||||||
if autoconvert:
|
|
||||||
img, save_kwargs = prepare_image(img, format)
|
|
||||||
options = dict(save_kwargs.items() + options.items())
|
|
||||||
|
|
||||||
# Attempt to reset the file pointer.
|
|
||||||
try:
|
try:
|
||||||
outfile.seek(0)
|
from importlib import import_module
|
||||||
except AttributeError:
|
except ImportError:
|
||||||
pass
|
from django.utils.importlib import import_module
|
||||||
|
from django.utils.module_loading import module_has_submodule
|
||||||
|
|
||||||
try:
|
for app in settings.INSTALLED_APPS:
|
||||||
with quiet():
|
# As of Django 1.7, settings.INSTALLED_APPS may contain classes instead of modules, hence the try/except
|
||||||
img.save(outfile, format, **options)
|
# See here: https://docs.djangoproject.com/en/dev/releases/1.7/#introspecting-applications
|
||||||
except IOError:
|
|
||||||
# PIL can have problems saving large JPEGs if MAXBLOCK isn't big enough,
|
|
||||||
# So if we have a problem saving, we temporarily increase it. See
|
|
||||||
# http://github.com/jdriscoll/django-imagekit/issues/50
|
|
||||||
old_maxblock = ImageFile.MAXBLOCK
|
|
||||||
ImageFile.MAXBLOCK = img.size[0] * img.size[1]
|
|
||||||
try:
|
try:
|
||||||
img.save(outfile, format, **options)
|
mod = import_module(app)
|
||||||
finally:
|
# Attempt to import the app's admin module.
|
||||||
ImageFile.MAXBLOCK = old_maxblock
|
|
||||||
|
|
||||||
try:
|
|
||||||
outfile.seek(0)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return outfile
|
|
||||||
|
|
||||||
|
|
||||||
class quiet(object):
|
|
||||||
"""
|
|
||||||
A context manager for suppressing the stderr activity of PIL's C libraries.
|
|
||||||
Based on http://stackoverflow.com/a/978264/155370
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __enter__(self):
|
|
||||||
self.stderr_fd = sys.__stderr__.fileno()
|
|
||||||
self.null_fd = os.open(os.devnull, os.O_RDWR)
|
|
||||||
self.old = os.dup(self.stderr_fd)
|
|
||||||
os.dup2(self.null_fd, self.stderr_fd)
|
|
||||||
|
|
||||||
def __exit__(self, *args, **kwargs):
|
|
||||||
os.dup2(self.old, self.stderr_fd)
|
|
||||||
os.close(self.null_fd)
|
|
||||||
os.close(self.old)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_image(img, format):
|
|
||||||
"""
|
|
||||||
Prepares the image for saving to the provided format by doing some
|
|
||||||
common-sense conversions. This includes things like preserving transparency
|
|
||||||
and quantizing. This function is used automatically by ``save_image()``
|
|
||||||
(and classes like ``ImageSpecField`` and ``ProcessedImageField``)
|
|
||||||
immediately before saving unless you specify ``autoconvert=False``. It is
|
|
||||||
provided as a utility for those doing their own processing.
|
|
||||||
|
|
||||||
:param img: The image to prepare for saving.
|
|
||||||
:param format: The format that the image will be saved to.
|
|
||||||
|
|
||||||
"""
|
|
||||||
matte = False
|
|
||||||
save_kwargs = {}
|
|
||||||
|
|
||||||
if img.mode == 'RGBA':
|
|
||||||
if format in RGBA_TRANSPARENCY_FORMATS:
|
|
||||||
pass
|
|
||||||
elif format in PALETTE_TRANSPARENCY_FORMATS:
|
|
||||||
# If you're going from a format with alpha transparency to one
|
|
||||||
# with palette transparency, transparency values will be
|
|
||||||
# snapped: pixels that are more opaque than not will become
|
|
||||||
# fully opaque; pixels that are more transparent than not will
|
|
||||||
# become fully transparent. This will not produce a good-looking
|
|
||||||
# result if your image contains varying levels of opacity; in
|
|
||||||
# that case, you'll probably want to use a processor to matte
|
|
||||||
# the image on a solid color. The reason we don't matte by
|
|
||||||
# default is because not doing so allows processors to treat
|
|
||||||
# RGBA-format images as a super-type of P-format images: if you
|
|
||||||
# have an RGBA-format image with only a single transparent
|
|
||||||
# color, and save it as a GIF, it will retain its transparency.
|
|
||||||
# In other words, a P-format image converted to an
|
|
||||||
# RGBA-formatted image by a processor and then saved as a
|
|
||||||
# P-format image will give the expected results.
|
|
||||||
|
|
||||||
# Work around a bug in PIL: split() doesn't check to see if
|
|
||||||
# img is loaded.
|
|
||||||
img.load()
|
|
||||||
|
|
||||||
alpha = img.split()[-1]
|
|
||||||
mask = Image.eval(alpha, lambda a: 255 if a <= 128 else 0)
|
|
||||||
img = img.convert('RGB').convert('P', palette=Image.ADAPTIVE,
|
|
||||||
colors=255)
|
|
||||||
img.paste(255, mask)
|
|
||||||
save_kwargs['transparency'] = 255
|
|
||||||
else:
|
|
||||||
# Simply converting an RGBA-format image to an RGB one creates a
|
|
||||||
# gross result, so we matte the image on a white background. If
|
|
||||||
# that's not what you want, that's fine: use a processor to deal
|
|
||||||
# with the transparency however you want. This is simply a
|
|
||||||
# sensible default that will always produce something that looks
|
|
||||||
# good. Or at least, it will look better than just a straight
|
|
||||||
# conversion.
|
|
||||||
matte = True
|
|
||||||
elif img.mode == 'P':
|
|
||||||
if format in PALETTE_TRANSPARENCY_FORMATS:
|
|
||||||
try:
|
try:
|
||||||
save_kwargs['transparency'] = img.info['transparency']
|
import_module('%s.imagegenerators' % app)
|
||||||
except KeyError:
|
except:
|
||||||
pass
|
# Decide whether to bubble up this error. If the app just
|
||||||
elif format in RGBA_TRANSPARENCY_FORMATS:
|
# doesn't have an imagegenerators module, we can ignore the error
|
||||||
# Currently PIL doesn't support any RGBA-mode formats that
|
# attempting to import it, otherwise we want it to bubble up.
|
||||||
# aren't also P-mode formats, so this will never happen.
|
if module_has_submodule(mod, 'imagegenerators'):
|
||||||
img = img.convert('RGBA')
|
raise
|
||||||
else:
|
except ImportError:
|
||||||
matte = True
|
pass
|
||||||
else:
|
|
||||||
img = img.convert('RGB')
|
|
||||||
|
|
||||||
# GIFs are always going to be in palette mode, so we can do a little
|
|
||||||
# optimization. Note that the RGBA sources also use adaptive
|
|
||||||
# quantization (above). Images that are already in P mode don't need
|
|
||||||
# any quantization because their colors are already limited.
|
|
||||||
if format == 'GIF':
|
|
||||||
img = img.convert('P', palette=Image.ADAPTIVE)
|
|
||||||
|
|
||||||
if matte:
|
|
||||||
img = img.convert('RGBA')
|
|
||||||
bg = Image.new('RGBA', img.size, (255, 255, 255))
|
|
||||||
bg.paste(img, img)
|
|
||||||
img = bg.convert('RGB')
|
|
||||||
|
|
||||||
if format == 'JPEG':
|
|
||||||
save_kwargs['optimize'] = True
|
|
||||||
|
|
||||||
return img, save_kwargs
|
|
||||||
|
|
||||||
|
|
||||||
def ik_model_receiver(fn):
|
def get_logger(logger_name='imagekit', add_null_handler=True):
|
||||||
@wraps(fn)
|
logger = logging.getLogger(logger_name)
|
||||||
def receiver(sender, **kwargs):
|
if add_null_handler:
|
||||||
if getattr(sender, '_ik', None):
|
logger.addHandler(NullHandler())
|
||||||
fn(sender, **kwargs)
|
return logger
|
||||||
return receiver
|
|
||||||
|
|
||||||
|
def get_field_info(field_file):
|
||||||
|
"""
|
||||||
|
A utility for easily extracting information about the host model from a
|
||||||
|
Django FileField (or subclass). This is especially useful for when you want
|
||||||
|
to alter processors based on a property of the source model. For example::
|
||||||
|
|
||||||
|
class MySpec(ImageSpec):
|
||||||
|
def __init__(self, source):
|
||||||
|
instance, attname = get_field_info(source)
|
||||||
|
self.processors = [SmartResize(instance.thumbnail_width,
|
||||||
|
instance.thumbnail_height)]
|
||||||
|
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
getattr(field_file, 'instance', None),
|
||||||
|
getattr(getattr(field_file, 'field', None), 'attname', None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate(generator):
|
||||||
|
"""
|
||||||
|
Calls the ``generate()`` method of a generator instance, and then wraps the
|
||||||
|
result in a Django File object so Django knows how to save it.
|
||||||
|
|
||||||
|
"""
|
||||||
|
content = generator.generate()
|
||||||
|
f = File(content)
|
||||||
|
# The size of the File must be known or Django will try to open a file
|
||||||
|
# without a name and raise an Exception.
|
||||||
|
f.size = len(content.read())
|
||||||
|
# After getting the size reset the file pointer for future reads.
|
||||||
|
content.seek(0)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def call_strategy_method(file, method_name):
|
||||||
|
strategy = getattr(file, 'cachefile_strategy', None)
|
||||||
|
fn = getattr(strategy, method_name, None)
|
||||||
|
if fn is not None:
|
||||||
|
fn(file)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache():
|
||||||
|
try:
|
||||||
|
from django.core.cache import caches
|
||||||
|
except ImportError:
|
||||||
|
# Django < 1.7
|
||||||
|
from django.core.cache import get_cache
|
||||||
|
return get_cache(settings.IMAGEKIT_CACHE_BACKEND)
|
||||||
|
|
||||||
|
return caches[settings.IMAGEKIT_CACHE_BACKEND]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_cache_key(key):
|
||||||
|
if settings.IMAGEKIT_USE_MEMCACHED_SAFE_CACHE_KEY:
|
||||||
|
# Memcached keys can't contain whitespace or control characters.
|
||||||
|
new_key = bad_memcached_key_chars.sub('', key)
|
||||||
|
|
||||||
|
# The also can't be > 250 chars long. Since we don't know what the
|
||||||
|
# user's cache ``KEY_FUNCTION`` setting is like, we'll limit it to 200.
|
||||||
|
if len(new_key) >= 200:
|
||||||
|
new_key = '%s:%s' % (new_key[:200-33], md5(force_bytes(key)).hexdigest())
|
||||||
|
|
||||||
|
key = new_key
|
||||||
|
return key
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
Django>=1.3.1
|
|
||||||
django-appconf>=0.5
|
|
||||||
PIL>=1.1.7
|
|
||||||
2
setup.cfg
Normal file
2
setup.cfg
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[bdist_wheel]
|
||||||
|
universal = 1
|
||||||
58
setup.py
58
setup.py
|
|
@ -1,36 +1,67 @@
|
||||||
#/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
import codecs
|
import codecs
|
||||||
import os
|
import os
|
||||||
|
from setuptools import setup, find_packages
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from setuptools import setup, find_packages
|
|
||||||
|
# Workaround for multiprocessing/nose issue. See http://bugs.python.org/msg170215
|
||||||
|
try:
|
||||||
|
import multiprocessing # NOQA
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
if 'publish' in sys.argv:
|
if 'publish' in sys.argv:
|
||||||
os.system('python setup.py sdist upload')
|
os.system('python setup.py sdist bdist_wheel upload')
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read()
|
read = lambda filepath: codecs.open(filepath, 'r', 'utf-8').read()
|
||||||
|
|
||||||
# Dynamically calculate the version based on imagekit.VERSION.
|
|
||||||
version = __import__('imagekit').get_version()
|
def exec_file(filepath, globalz=None, localz=None):
|
||||||
|
exec(read(filepath), globalz, localz)
|
||||||
|
|
||||||
|
|
||||||
|
# Load package meta from the pkgmeta module without loading imagekit.
|
||||||
|
pkgmeta = {}
|
||||||
|
exec_file(os.path.join(os.path.dirname(__file__),
|
||||||
|
'imagekit', 'pkgmeta.py'), pkgmeta)
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='django-imagekit',
|
name='django-imagekit',
|
||||||
version=version,
|
version=pkgmeta['__version__'],
|
||||||
description='Automated image processing for Django models.',
|
description='Automated image processing for Django models.',
|
||||||
long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')),
|
long_description=read(os.path.join(os.path.dirname(__file__), 'README.rst')),
|
||||||
author='Justin Driscoll',
|
author='Matthew Tretter',
|
||||||
author_email='justin@driscolldev.com',
|
author_email='m@tthewwithanm.com',
|
||||||
maintainer='Bryan Veloso',
|
maintainer='Bryan Veloso',
|
||||||
maintainer_email='bryan@revyver.com',
|
maintainer_email='bryan@revyver.com',
|
||||||
license='BSD',
|
license='BSD',
|
||||||
url='http://github.com/jdriscoll/django-imagekit/',
|
url='http://github.com/matthewwithanm/django-imagekit/',
|
||||||
packages=find_packages(),
|
packages=find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']),
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
|
tests_require=[
|
||||||
|
'beautifulsoup4>=4.4.0',
|
||||||
|
'nose>=1.3.6',
|
||||||
|
'nose-progressive>=1.5.1',
|
||||||
|
'django-nose>=1.4',
|
||||||
|
'Pillow',
|
||||||
|
'mock>=1.0.1',
|
||||||
|
],
|
||||||
|
test_suite='testrunner.run_tests',
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'django-appconf>=0.5',
|
'django-appconf>=0.5',
|
||||||
|
'pilkit>=0.2.0',
|
||||||
|
'six',
|
||||||
],
|
],
|
||||||
|
extras_require={
|
||||||
|
'async': ['django-celery>=3.0'],
|
||||||
|
'async_rq': ['django-rq>=0.6.0'],
|
||||||
|
},
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Development Status :: 5 - Production/Stable',
|
'Development Status :: 5 - Production/Stable',
|
||||||
'Environment :: Web Environment',
|
'Environment :: Web Environment',
|
||||||
|
|
@ -38,9 +69,12 @@ setup(
|
||||||
'Intended Audience :: Developers',
|
'Intended Audience :: Developers',
|
||||||
'License :: OSI Approved :: BSD License',
|
'License :: OSI Approved :: BSD License',
|
||||||
'Operating System :: OS Independent',
|
'Operating System :: OS Independent',
|
||||||
'Programming Language :: Python :: 2.5',
|
'Programming Language :: Python :: 2',
|
||||||
'Programming Language :: Python :: 2.6',
|
|
||||||
'Programming Language :: Python :: 2.7',
|
'Programming Language :: Python :: 2.7',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
'Programming Language :: Python :: 3.3',
|
||||||
|
'Programming Language :: Python :: 3.4',
|
||||||
|
'Programming Language :: Python :: 3.5',
|
||||||
'Topic :: Utilities'
|
'Topic :: Utilities'
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
22
testrunner.py
Normal file
22
testrunner.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# A wrapper for Django's test runner.
|
||||||
|
# See http://ericholscher.com/blog/2009/jun/29/enable-setuppy-test-your-django-apps/
|
||||||
|
# and http://gremu.net/blog/2010/enable-setuppy-test-your-django-apps/
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings'
|
||||||
|
test_dir = os.path.dirname(__file__)
|
||||||
|
sys.path.insert(0, test_dir)
|
||||||
|
|
||||||
|
from django.test.utils import get_runner
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def run_tests():
|
||||||
|
cls = get_runner(settings)
|
||||||
|
runner = cls()
|
||||||
|
failures = runner.run_tests(['tests'])
|
||||||
|
# Clean autogenerated junk before exit
|
||||||
|
from tests.utils import clear_imagekit_test_files
|
||||||
|
clear_imagekit_test_files()
|
||||||
|
sys.exit(failures)
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 463 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 206 KiB |
|
|
@ -1,34 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
from imagekit.models import ImageSpecField
|
|
||||||
from imagekit.processors import Adjust
|
|
||||||
from imagekit.processors import ResizeToFill
|
|
||||||
from imagekit.processors import SmartCrop
|
|
||||||
|
|
||||||
|
|
||||||
class Photo(models.Model):
|
|
||||||
original_image = models.ImageField(upload_to='photos')
|
|
||||||
|
|
||||||
thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
|
|
||||||
ResizeToFill(50, 50)], image_field='original_image', format='JPEG',
|
|
||||||
options={'quality': 90})
|
|
||||||
|
|
||||||
smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2,
|
|
||||||
sharpness=1.1), SmartCrop(50, 50)], image_field='original_image',
|
|
||||||
format='JPEG', options={'quality': 90})
|
|
||||||
|
|
||||||
|
|
||||||
class AbstractImageModel(models.Model):
|
|
||||||
original_image = models.ImageField(upload_to='photos')
|
|
||||||
abstract_class_spec = ImageSpecField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
|
||||||
class ConcreteImageModel1(AbstractImageModel):
|
|
||||||
first_spec = ImageSpecField()
|
|
||||||
|
|
||||||
|
|
||||||
class ConcreteImageModel2(AbstractImageModel):
|
|
||||||
second_spec = ImageSpecField()
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
from __future__ import with_statement
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from imagekit import utils
|
|
||||||
from .models import (Photo, AbstractImageModel, ConcreteImageModel1,
|
|
||||||
ConcreteImageModel2)
|
|
||||||
from .testutils import create_photo, pickleback
|
|
||||||
|
|
||||||
|
|
||||||
class IKTest(TestCase):
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.photo = create_photo('test.jpg')
|
|
||||||
|
|
||||||
def test_nodelete(self):
|
|
||||||
"""Don't delete the spec file when the source image hasn't changed.
|
|
||||||
|
|
||||||
"""
|
|
||||||
filename = self.photo.thumbnail.file.name
|
|
||||||
self.photo.save()
|
|
||||||
self.assertTrue(self.photo.thumbnail.storage.exists(filename))
|
|
||||||
|
|
||||||
def test_save_image(self):
|
|
||||||
photo = Photo.objects.get(id=self.photo.id)
|
|
||||||
self.assertTrue(os.path.isfile(photo.original_image.path))
|
|
||||||
|
|
||||||
def test_setup(self):
|
|
||||||
self.assertEqual(self.photo.original_image.width, 800)
|
|
||||||
self.assertEqual(self.photo.original_image.height, 600)
|
|
||||||
|
|
||||||
def test_thumbnail_creation(self):
|
|
||||||
photo = Photo.objects.get(id=self.photo.id)
|
|
||||||
self.assertTrue(os.path.isfile(photo.thumbnail.file.name))
|
|
||||||
|
|
||||||
def test_thumbnail_size(self):
|
|
||||||
""" Explicit and smart-cropped thumbnail size """
|
|
||||||
self.assertEqual(self.photo.thumbnail.width, 50)
|
|
||||||
self.assertEqual(self.photo.thumbnail.height, 50)
|
|
||||||
self.assertEqual(self.photo.smartcropped_thumbnail.width, 50)
|
|
||||||
self.assertEqual(self.photo.smartcropped_thumbnail.height, 50)
|
|
||||||
|
|
||||||
def test_thumbnail_source_file(self):
|
|
||||||
self.assertEqual(
|
|
||||||
self.photo.thumbnail.source_file, self.photo.original_image)
|
|
||||||
|
|
||||||
|
|
||||||
class IKUtilsTest(TestCase):
|
|
||||||
def test_extension_to_format(self):
|
|
||||||
self.assertEqual(utils.extension_to_format('.jpeg'), 'JPEG')
|
|
||||||
self.assertEqual(utils.extension_to_format('.rgba'), 'SGI')
|
|
||||||
|
|
||||||
self.assertRaises(utils.UnknownExtensionError,
|
|
||||||
lambda: utils.extension_to_format('.txt'))
|
|
||||||
|
|
||||||
def test_format_to_extension_no_init(self):
|
|
||||||
self.assertEqual(utils.format_to_extension('PNG'), '.png')
|
|
||||||
self.assertEqual(utils.format_to_extension('ICO'), '.ico')
|
|
||||||
|
|
||||||
self.assertRaises(utils.UnknownFormatError,
|
|
||||||
lambda: utils.format_to_extension('TXT'))
|
|
||||||
|
|
||||||
|
|
||||||
class PickleTest(TestCase):
|
|
||||||
def test_model(self):
|
|
||||||
ph = pickleback(create_photo('pickletest.jpg'))
|
|
||||||
|
|
||||||
# This isn't supposed to error.
|
|
||||||
ph.thumbnail.source_file
|
|
||||||
|
|
||||||
def test_field(self):
|
|
||||||
thumbnail = pickleback(create_photo('pickletest2.jpg').thumbnail)
|
|
||||||
|
|
||||||
# This isn't supposed to error.
|
|
||||||
thumbnail.source_file
|
|
||||||
|
|
||||||
|
|
||||||
class InheritanceTest(TestCase):
|
|
||||||
def test_abstract_base(self):
|
|
||||||
self.assertEqual(set(AbstractImageModel._ik.spec_fields),
|
|
||||||
set(['abstract_class_spec']))
|
|
||||||
self.assertEqual(set(ConcreteImageModel1._ik.spec_fields),
|
|
||||||
set(['abstract_class_spec', 'first_spec']))
|
|
||||||
self.assertEqual(set(ConcreteImageModel2._ik.spec_fields),
|
|
||||||
set(['abstract_class_spec', 'second_spec']))
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
|
|
||||||
from imagekit.lib import Image, StringIO
|
|
||||||
from .models import Photo
|
|
||||||
import pickle
|
|
||||||
|
|
||||||
|
|
||||||
def generate_lenna():
|
|
||||||
"""
|
|
||||||
See also:
|
|
||||||
|
|
||||||
http://en.wikipedia.org/wiki/Lenna
|
|
||||||
http://sipi.usc.edu/database/database.php?volume=misc&image=12
|
|
||||||
|
|
||||||
"""
|
|
||||||
tmp = tempfile.TemporaryFile()
|
|
||||||
lennapath = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets', 'lenna-800x600-white-border.jpg')
|
|
||||||
with open(lennapath, "r+b") as lennafile:
|
|
||||||
Image.open(lennafile).save(tmp, 'JPEG')
|
|
||||||
tmp.seek(0)
|
|
||||||
return tmp
|
|
||||||
|
|
||||||
|
|
||||||
def create_instance(model_class, image_name):
|
|
||||||
instance = model_class()
|
|
||||||
img = generate_lenna()
|
|
||||||
file = ContentFile(img.read())
|
|
||||||
instance.original_image = file
|
|
||||||
instance.original_image.save(image_name, file)
|
|
||||||
instance.save()
|
|
||||||
img.close()
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
def create_photo(name):
|
|
||||||
return create_instance(Photo, name)
|
|
||||||
|
|
||||||
|
|
||||||
def pickleback(obj):
|
|
||||||
pickled = StringIO()
|
|
||||||
pickle.dump(obj, pickled)
|
|
||||||
pickled.seek(0)
|
|
||||||
return pickle.load(pickled)
|
|
||||||
16
tests/imagegenerators.py
Normal file
16
tests/imagegenerators.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from imagekit import ImageSpec, register
|
||||||
|
from imagekit.processors import ResizeToFill
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpec(ImageSpec):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ResizeTo1PixelSquare(ImageSpec):
|
||||||
|
def __init__(self, width=None, height=None, anchor=None, crop=None, **kwargs):
|
||||||
|
self.processors = [ResizeToFill(1, 1)]
|
||||||
|
super(ResizeTo1PixelSquare, self).__init__(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
register.generator('testspec', TestSpec)
|
||||||
|
register.generator('1pxsq', ResizeTo1PixelSquare)
|
||||||
BIN
tests/media/reference.png
Normal file
BIN
tests/media/reference.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
72
tests/models.py
Normal file
72
tests/models.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from imagekit import ImageSpec
|
||||||
|
from imagekit.models import ProcessedImageField
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import Adjust, ResizeToFill, SmartCrop
|
||||||
|
|
||||||
|
|
||||||
|
class Thumbnail(ImageSpec):
|
||||||
|
processors = [ResizeToFill(100, 60)]
|
||||||
|
format = 'JPEG'
|
||||||
|
options = {'quality': 60}
|
||||||
|
|
||||||
|
|
||||||
|
class ImageModel(models.Model):
|
||||||
|
image = models.ImageField(upload_to='b')
|
||||||
|
|
||||||
|
|
||||||
|
class Photo(models.Model):
|
||||||
|
original_image = models.ImageField(upload_to='photos')
|
||||||
|
|
||||||
|
# Implicit source field
|
||||||
|
thumbnail = ImageSpecField([Adjust(contrast=1.2, sharpness=1.1),
|
||||||
|
ResizeToFill(50, 50)], format='JPEG',
|
||||||
|
options={'quality': 90})
|
||||||
|
|
||||||
|
smartcropped_thumbnail = ImageSpecField([Adjust(contrast=1.2,
|
||||||
|
sharpness=1.1), SmartCrop(50, 50)], source='original_image',
|
||||||
|
format='JPEG', options={'quality': 90})
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedImageFieldModel(models.Model):
|
||||||
|
processed = ProcessedImageField([SmartCrop(50, 50)], format='JPEG',
|
||||||
|
options={'quality': 90}, upload_to='p')
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessedImageFieldWithSpecModel(models.Model):
|
||||||
|
processed = ProcessedImageField(spec=Thumbnail, upload_to='p')
|
||||||
|
|
||||||
|
|
||||||
|
class CountingCacheFileStrategy(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.on_existence_required_count = 0
|
||||||
|
self.on_content_required_count = 0
|
||||||
|
self.on_source_saved_count = 0
|
||||||
|
|
||||||
|
def on_existence_required(self, file):
|
||||||
|
self.on_existence_required_count += 1
|
||||||
|
|
||||||
|
def on_content_required(self, file):
|
||||||
|
self.on_content_required_count += 1
|
||||||
|
|
||||||
|
def on_source_saved(self, file):
|
||||||
|
self.on_source_saved_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractImageModel(models.Model):
|
||||||
|
original_image = models.ImageField(upload_to='photos')
|
||||||
|
abstract_class_spec = ImageSpecField(source='original_image',
|
||||||
|
format='JPEG',
|
||||||
|
cachefile_strategy=CountingCacheFileStrategy())
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class ConcreteImageModel(AbstractImageModel):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConcreteImageModelSubclass(ConcreteImageModel):
|
||||||
|
pass
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
PYTHONPATH=$PWD:$PWD/..${PYTHONPATH:+:$PYTHONPATH}
|
|
||||||
export PYTHONPATH
|
|
||||||
|
|
||||||
echo "Running django-imagekit tests..."
|
|
||||||
django-admin.py test core --settings=settings
|
|
||||||
|
|
@ -21,13 +21,49 @@ DATABASES = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SECRET_KEY = '_uobce43e5osp8xgzle*yag2_16%y$sf*5(12vfg25hpnxik_*'
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'imagekit',
|
'imagekit',
|
||||||
'core',
|
'tests',
|
||||||
|
'django_nose',
|
||||||
]
|
]
|
||||||
|
|
||||||
DEBUG = True
|
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
|
||||||
TEMPLATE_DEBUG = DEBUG
|
NOSE_ARGS = [
|
||||||
|
'-s',
|
||||||
|
|
||||||
|
# When the tests are run --with-coverage, these args configure coverage
|
||||||
|
# reporting (requires coverage to be installed).
|
||||||
|
# Without the --with-coverage flag, they have no effect.
|
||||||
|
'--cover-tests',
|
||||||
|
'--cover-html',
|
||||||
|
'--cover-package=imagekit',
|
||||||
|
'--cover-html-dir=%s' % os.path.join(BASE_PATH, 'cover')
|
||||||
|
]
|
||||||
|
|
||||||
|
if os.getenv('TERM'):
|
||||||
|
NOSE_ARGS.append('--with-progressive')
|
||||||
|
|
||||||
CACHE_BACKEND = 'locmem://'
|
CACHE_BACKEND = 'locmem://'
|
||||||
|
|
||||||
|
# Django >= 1.8
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.i18n',
|
||||||
|
'django.template.context_processors.media',
|
||||||
|
'django.template.context_processors.static',
|
||||||
|
'django.template.context_processors.tz',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
|
||||||
9
tests/test_abstract_models.py
Normal file
9
tests/test_abstract_models.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from imagekit.utils import get_nonabstract_descendants
|
||||||
|
from nose.tools import eq_
|
||||||
|
from . models import (AbstractImageModel, ConcreteImageModel,
|
||||||
|
ConcreteImageModelSubclass)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonabstract_descendants_generator():
|
||||||
|
descendants = list(get_nonabstract_descendants(AbstractImageModel))
|
||||||
|
eq_(descendants, [ConcreteImageModel, ConcreteImageModelSubclass])
|
||||||
115
tests/test_cachefiles.py
Normal file
115
tests/test_cachefiles.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
from unittest import mock
|
||||||
|
from django.conf import settings
|
||||||
|
from hashlib import md5
|
||||||
|
from imagekit.cachefiles import ImageCacheFile, LazyImageCacheFile
|
||||||
|
from imagekit.cachefiles.backends import Simple
|
||||||
|
from imagekit.lib import force_bytes
|
||||||
|
from nose.tools import raises, eq_
|
||||||
|
from .imagegenerators import TestSpec
|
||||||
|
from .utils import (assert_file_is_truthy, assert_file_is_falsy,
|
||||||
|
DummyAsyncCacheFileBackend, get_unique_image_file,
|
||||||
|
get_image_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_source_falsiness():
|
||||||
|
"""
|
||||||
|
Ensure cache files generated from sourceless specs are falsy.
|
||||||
|
|
||||||
|
"""
|
||||||
|
spec = TestSpec(source=None)
|
||||||
|
file = ImageCacheFile(spec)
|
||||||
|
assert_file_is_falsy(file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_backend_truthiness():
|
||||||
|
"""
|
||||||
|
Ensure that a cachefile with a synchronous cache file backend (the default)
|
||||||
|
is truthy.
|
||||||
|
|
||||||
|
"""
|
||||||
|
spec = TestSpec(source=get_unique_image_file())
|
||||||
|
file = ImageCacheFile(spec)
|
||||||
|
assert_file_is_truthy(file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_backend_falsiness():
|
||||||
|
"""
|
||||||
|
Ensure that a cachefile with an asynchronous cache file backend is falsy.
|
||||||
|
|
||||||
|
"""
|
||||||
|
spec = TestSpec(source=get_unique_image_file())
|
||||||
|
file = ImageCacheFile(spec, cachefile_backend=DummyAsyncCacheFileBackend())
|
||||||
|
assert_file_is_falsy(file)
|
||||||
|
|
||||||
|
|
||||||
|
@raises(TestSpec.MissingSource)
|
||||||
|
def test_no_source_error():
|
||||||
|
spec = TestSpec(source=None)
|
||||||
|
file = ImageCacheFile(spec)
|
||||||
|
file.generate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_repr_does_not_send_existence_required():
|
||||||
|
"""
|
||||||
|
Ensure that `__repr__` method does not send `existance_required` signal
|
||||||
|
|
||||||
|
Cachefile strategy may be configured to generate file on
|
||||||
|
`existance_required`.
|
||||||
|
To generate images, backend passes `ImageCacheFile` instance to worker.
|
||||||
|
Both celery and RQ calls `__repr__` method for each argument to enque call.
|
||||||
|
And if `__repr__` of object will send this signal, we will get endless
|
||||||
|
recursion
|
||||||
|
|
||||||
|
"""
|
||||||
|
with mock.patch('imagekit.cachefiles.existence_required') as signal:
|
||||||
|
# import here to apply mock
|
||||||
|
from imagekit.cachefiles import ImageCacheFile
|
||||||
|
|
||||||
|
spec = TestSpec(source=get_unique_image_file())
|
||||||
|
file = ImageCacheFile(
|
||||||
|
spec,
|
||||||
|
cachefile_backend=DummyAsyncCacheFileBackend()
|
||||||
|
)
|
||||||
|
file.__repr__()
|
||||||
|
eq_(signal.send.called, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_memcached_cache_key():
|
||||||
|
"""
|
||||||
|
Ensure the default cachefile backend is sanitizing its cache key for
|
||||||
|
memcached by default.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
class MockFile(object):
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
backend = Simple()
|
||||||
|
extra_char_count = len('state-') + len(settings.IMAGEKIT_CACHE_PREFIX)
|
||||||
|
|
||||||
|
length = 199 - extra_char_count
|
||||||
|
filename = '1' * length
|
||||||
|
file = MockFile(filename)
|
||||||
|
eq_(backend.get_key(file), '%s%s-state' %
|
||||||
|
(settings.IMAGEKIT_CACHE_PREFIX, file.name))
|
||||||
|
|
||||||
|
length = 200 - extra_char_count
|
||||||
|
filename = '1' * length
|
||||||
|
file = MockFile(filename)
|
||||||
|
eq_(backend.get_key(file), '%s%s:%s' % (
|
||||||
|
settings.IMAGEKIT_CACHE_PREFIX,
|
||||||
|
'1' * (200 - len(':') - 32 - len(settings.IMAGEKIT_CACHE_PREFIX)),
|
||||||
|
md5(force_bytes('%s%s-state' % (settings.IMAGEKIT_CACHE_PREFIX, filename))).hexdigest()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_lazyfile_stringification():
|
||||||
|
file = LazyImageCacheFile('testspec', source=None)
|
||||||
|
eq_(str(file), '')
|
||||||
|
eq_(repr(file), '<ImageCacheFile: None>')
|
||||||
|
|
||||||
|
source_file = get_image_file()
|
||||||
|
file = LazyImageCacheFile('testspec', source=source_file)
|
||||||
|
file.name = 'a.jpg'
|
||||||
|
eq_(str(file), 'a.jpg')
|
||||||
|
eq_(repr(file), '<ImageCacheFile: a.jpg>')
|
||||||
25
tests/test_closing_fieldfiles.py
Normal file
25
tests/test_closing_fieldfiles.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from nose.tools import assert_false, assert_true
|
||||||
|
|
||||||
|
from .models import Thumbnail
|
||||||
|
from .utils import create_photo
|
||||||
|
|
||||||
|
|
||||||
|
def test_do_not_leak_open_files():
|
||||||
|
instance = create_photo('leak-test.jpg')
|
||||||
|
source_file = instance.original_image
|
||||||
|
# Ensure the FieldFile is closed before generation
|
||||||
|
source_file.close()
|
||||||
|
image_generator = Thumbnail(source=source_file)
|
||||||
|
image_generator.generate()
|
||||||
|
assert_true(source_file.closed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_do_not_close_open_files_after_generate():
|
||||||
|
instance = create_photo('do-not-close-test.jpg')
|
||||||
|
source_file = instance.original_image
|
||||||
|
# Ensure the FieldFile is opened before generation
|
||||||
|
source_file.open()
|
||||||
|
image_generator = Thumbnail(source=source_file)
|
||||||
|
image_generator.generate()
|
||||||
|
assert_false(source_file.closed)
|
||||||
|
source_file.close()
|
||||||
49
tests/test_fields.py
Normal file
49
tests/test_fields.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from django import forms
|
||||||
|
from django.core.files.base import File
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from imagekit import forms as ikforms
|
||||||
|
from imagekit.processors import SmartCrop
|
||||||
|
from nose.tools import eq_
|
||||||
|
from . import imagegenerators # noqa
|
||||||
|
from .models import (ProcessedImageFieldModel,
|
||||||
|
ProcessedImageFieldWithSpecModel,
|
||||||
|
ImageModel)
|
||||||
|
from .utils import get_image_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_processedimagefield():
|
||||||
|
instance = ProcessedImageFieldModel()
|
||||||
|
file = File(get_image_file())
|
||||||
|
instance.processed.save('whatever.jpeg', file)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
eq_(instance.processed.width, 50)
|
||||||
|
eq_(instance.processed.height, 50)
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_processedimagefield_with_spec():
|
||||||
|
instance = ProcessedImageFieldWithSpecModel()
|
||||||
|
file = File(get_image_file())
|
||||||
|
instance.processed.save('whatever.jpeg', file)
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
eq_(instance.processed.width, 100)
|
||||||
|
eq_(instance.processed.height, 60)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_processedimagefield():
|
||||||
|
class TestForm(forms.ModelForm):
|
||||||
|
image = ikforms.ProcessedImageField(spec_id='tests:testform_image',
|
||||||
|
processors=[SmartCrop(50, 50)], format='JPEG')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ImageModel
|
||||||
|
fields = 'image',
|
||||||
|
|
||||||
|
upload_file = get_image_file()
|
||||||
|
file_dict = {'image': SimpleUploadedFile('abc.jpg', upload_file.read())}
|
||||||
|
form = TestForm({}, file_dict)
|
||||||
|
instance = form.save()
|
||||||
|
|
||||||
|
eq_(instance.image.width, 50)
|
||||||
|
eq_(instance.image.height, 50)
|
||||||
56
tests/test_generateimage_tag.py
Normal file
56
tests/test_generateimage_tag.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
from django.template import TemplateSyntaxError
|
||||||
|
from nose.tools import eq_, assert_false, raises, assert_not_equal
|
||||||
|
from . import imagegenerators # noqa
|
||||||
|
from .utils import render_tag, get_html_attrs, clear_imagekit_cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_tag():
|
||||||
|
ttag = r"""{% generateimage 'testspec' source=img %}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
attrs = get_html_attrs(ttag)
|
||||||
|
expected_attrs = set(['src', 'width', 'height'])
|
||||||
|
eq_(set(attrs.keys()), expected_attrs)
|
||||||
|
for k in expected_attrs:
|
||||||
|
assert_not_equal(attrs[k].strip(), '')
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_tag_attrs():
|
||||||
|
ttag = r"""{% generateimage 'testspec' source=img -- alt="Hello" %}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
attrs = get_html_attrs(ttag)
|
||||||
|
eq_(attrs.get('alt'), 'Hello')
|
||||||
|
|
||||||
|
|
||||||
|
@raises(TemplateSyntaxError)
|
||||||
|
def test_dangling_html_attrs_delimiter():
|
||||||
|
ttag = r"""{% generateimage 'testspec' source=img -- %}"""
|
||||||
|
render_tag(ttag)
|
||||||
|
|
||||||
|
|
||||||
|
@raises(TemplateSyntaxError)
|
||||||
|
def test_html_attrs_assignment():
|
||||||
|
"""
|
||||||
|
You can either use generateimage as an assignment tag or specify html attrs,
|
||||||
|
but not both.
|
||||||
|
|
||||||
|
"""
|
||||||
|
ttag = r"""{% generateimage 'testspec' source=img -- alt="Hello" as th %}"""
|
||||||
|
render_tag(ttag)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_dimension_attr():
|
||||||
|
"""
|
||||||
|
If you only provide one of width or height, the other should not be added.
|
||||||
|
|
||||||
|
"""
|
||||||
|
ttag = r"""{% generateimage 'testspec' source=img -- width="50" %}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
attrs = get_html_attrs(ttag)
|
||||||
|
assert_false('height' in attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignment_tag():
|
||||||
|
ttag = r"""{% generateimage 'testspec' source=img as th %}{{ th.url }}{{ th.height }}{{ th.width }}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
html = render_tag(ttag)
|
||||||
|
assert_not_equal(html.strip(), '')
|
||||||
16
tests/test_no_extra_queries.py
Normal file
16
tests/test_no_extra_queries.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from nose.tools import assert_false
|
||||||
|
from unittest.mock import Mock, PropertyMock, patch
|
||||||
|
from .models import Photo
|
||||||
|
|
||||||
|
|
||||||
|
def test_dont_access_source():
|
||||||
|
"""
|
||||||
|
Touching the source may trigger an unneeded query.
|
||||||
|
See <https://github.com/matthewwithanm/django-imagekit/issues/295>
|
||||||
|
|
||||||
|
"""
|
||||||
|
pmock = PropertyMock()
|
||||||
|
pmock.__get__ = Mock()
|
||||||
|
with patch.object(Photo, 'original_image', pmock):
|
||||||
|
photo = Photo() # noqa
|
||||||
|
assert_false(pmock.__get__.called)
|
||||||
49
tests/test_optimistic_strategy.py
Normal file
49
tests/test_optimistic_strategy.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from nose.tools import assert_true, assert_false
|
||||||
|
from imagekit.cachefiles import ImageCacheFile
|
||||||
|
from unittest.mock import Mock
|
||||||
|
from .utils import create_image
|
||||||
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
from imagekit.cachefiles.backends import Simple as SimpleCFBackend
|
||||||
|
from imagekit.cachefiles.strategies import Optimistic as OptimisticStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class ImageGenerator(object):
|
||||||
|
def generate(self):
|
||||||
|
return create_image()
|
||||||
|
|
||||||
|
def get_hash(self):
|
||||||
|
return 'abc123'
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_cache_file():
|
||||||
|
storage = Mock(FileSystemStorage)
|
||||||
|
backend = SimpleCFBackend()
|
||||||
|
strategy = OptimisticStrategy()
|
||||||
|
generator = ImageGenerator()
|
||||||
|
return ImageCacheFile(generator, storage=storage,
|
||||||
|
cachefile_backend=backend,
|
||||||
|
cachefile_strategy=strategy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_io_on_bool():
|
||||||
|
"""
|
||||||
|
When checking the truthiness of an ImageCacheFile, the storage shouldn't
|
||||||
|
peform IO operations.
|
||||||
|
|
||||||
|
"""
|
||||||
|
file = get_image_cache_file()
|
||||||
|
bool(file)
|
||||||
|
assert_false(file.storage.exists.called)
|
||||||
|
assert_false(file.storage.open.called)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_io_on_url():
|
||||||
|
"""
|
||||||
|
When getting the URL of an ImageCacheFile, the storage shouldn't be
|
||||||
|
checked.
|
||||||
|
|
||||||
|
"""
|
||||||
|
file = get_image_cache_file()
|
||||||
|
file.url
|
||||||
|
assert_false(file.storage.exists.called)
|
||||||
|
assert_false(file.storage.open.called)
|
||||||
43
tests/test_serialization.py
Normal file
43
tests/test_serialization.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""
|
||||||
|
Make sure that the various IK classes can be successfully serialized and
|
||||||
|
deserialized. This is important when using IK with Celery.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from imagekit.cachefiles import ImageCacheFile
|
||||||
|
from .imagegenerators import TestSpec
|
||||||
|
from .utils import create_photo, pickleback, get_unique_image_file, clear_imagekit_cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_imagespecfield():
|
||||||
|
clear_imagekit_cache()
|
||||||
|
instance = create_photo('pickletest2.jpg')
|
||||||
|
thumbnail = pickleback(instance.thumbnail)
|
||||||
|
thumbnail.generate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_circular_ref():
|
||||||
|
"""
|
||||||
|
A model instance with a spec field in its dict shouldn't raise a KeyError.
|
||||||
|
|
||||||
|
This corresponds to #234
|
||||||
|
|
||||||
|
"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
instance = create_photo('pickletest3.jpg')
|
||||||
|
instance.thumbnail # Cause thumbnail to be added to instance's __dict__
|
||||||
|
pickleback(instance)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cachefiles():
|
||||||
|
clear_imagekit_cache()
|
||||||
|
spec = TestSpec(source=get_unique_image_file())
|
||||||
|
file = ImageCacheFile(spec)
|
||||||
|
file.url
|
||||||
|
# remove link to file from spec source generator
|
||||||
|
# test __getstate__ of ImageCacheFile
|
||||||
|
file.generator.source = None
|
||||||
|
restored_file = pickleback(file)
|
||||||
|
assert file is not restored_file
|
||||||
|
# Assertion for #437 and #451
|
||||||
|
assert file.storage is restored_file.storage
|
||||||
55
tests/test_sourcegroups.py
Normal file
55
tests/test_sourcegroups.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
from django.core.files import File
|
||||||
|
from imagekit.signals import source_saved
|
||||||
|
from imagekit.specs.sourcegroups import ImageFieldSourceGroup
|
||||||
|
from nose.tools import eq_
|
||||||
|
from . models import AbstractImageModel, ImageModel, ConcreteImageModel
|
||||||
|
from .utils import get_image_file
|
||||||
|
|
||||||
|
|
||||||
|
def make_counting_receiver(source_group):
|
||||||
|
def receiver(sender, *args, **kwargs):
|
||||||
|
if sender is source_group:
|
||||||
|
receiver.count += 1
|
||||||
|
receiver.count = 0
|
||||||
|
return receiver
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_saved_signal():
|
||||||
|
"""
|
||||||
|
Creating a new instance with an image causes the source_saved signal to be
|
||||||
|
dispatched.
|
||||||
|
|
||||||
|
"""
|
||||||
|
source_group = ImageFieldSourceGroup(ImageModel, 'image')
|
||||||
|
receiver = make_counting_receiver(source_group)
|
||||||
|
source_saved.connect(receiver)
|
||||||
|
ImageModel.objects.create(image=File(get_image_file()))
|
||||||
|
eq_(receiver.count, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_source_saved_signal():
|
||||||
|
"""
|
||||||
|
Creating a new instance without an image shouldn't cause the source_saved
|
||||||
|
signal to be dispatched.
|
||||||
|
|
||||||
|
https://github.com/matthewwithanm/django-imagekit/issues/214
|
||||||
|
|
||||||
|
"""
|
||||||
|
source_group = ImageFieldSourceGroup(ImageModel, 'image')
|
||||||
|
receiver = make_counting_receiver(source_group)
|
||||||
|
source_saved.connect(receiver)
|
||||||
|
ImageModel.objects.create()
|
||||||
|
eq_(receiver.count, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_abstract_model_signals():
|
||||||
|
"""
|
||||||
|
Source groups created for abstract models must cause signals to be
|
||||||
|
dispatched on their concrete subclasses.
|
||||||
|
|
||||||
|
"""
|
||||||
|
source_group = ImageFieldSourceGroup(AbstractImageModel, 'original_image')
|
||||||
|
receiver = make_counting_receiver(source_group)
|
||||||
|
source_saved.connect(receiver)
|
||||||
|
ConcreteImageModel.objects.create(original_image=File(get_image_file()))
|
||||||
|
eq_(receiver.count, 1)
|
||||||
71
tests/test_thumbnail_tag.py
Normal file
71
tests/test_thumbnail_tag.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
from django.template import TemplateSyntaxError
|
||||||
|
from nose.tools import eq_, raises, assert_not_equal
|
||||||
|
from . import imagegenerators # noqa
|
||||||
|
from .utils import render_tag, get_html_attrs, clear_imagekit_cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_tag():
|
||||||
|
ttag = r"""{% thumbnail '100x100' img %}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
attrs = get_html_attrs(ttag)
|
||||||
|
expected_attrs = set(['src', 'width', 'height'])
|
||||||
|
eq_(set(attrs.keys()), expected_attrs)
|
||||||
|
for k in expected_attrs:
|
||||||
|
assert_not_equal(attrs[k].strip(), '')
|
||||||
|
|
||||||
|
|
||||||
|
def test_img_tag_attrs():
|
||||||
|
ttag = r"""{% thumbnail '100x100' img -- alt="Hello" %}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
attrs = get_html_attrs(ttag)
|
||||||
|
eq_(attrs.get('alt'), 'Hello')
|
||||||
|
|
||||||
|
|
||||||
|
@raises(TemplateSyntaxError)
|
||||||
|
def test_dangling_html_attrs_delimiter():
|
||||||
|
ttag = r"""{% thumbnail '100x100' img -- %}"""
|
||||||
|
render_tag(ttag)
|
||||||
|
|
||||||
|
|
||||||
|
@raises(TemplateSyntaxError)
|
||||||
|
def test_not_enough_args():
|
||||||
|
ttag = r"""{% thumbnail '100x100' %}"""
|
||||||
|
render_tag(ttag)
|
||||||
|
|
||||||
|
|
||||||
|
@raises(TemplateSyntaxError)
|
||||||
|
def test_too_many_args():
|
||||||
|
ttag = r"""{% thumbnail 'generator_id' '100x100' img 'extra' %}"""
|
||||||
|
render_tag(ttag)
|
||||||
|
|
||||||
|
|
||||||
|
@raises(TemplateSyntaxError)
|
||||||
|
def test_html_attrs_assignment():
|
||||||
|
"""
|
||||||
|
You can either use thumbnail as an assignment tag or specify html attrs,
|
||||||
|
but not both.
|
||||||
|
|
||||||
|
"""
|
||||||
|
ttag = r"""{% thumbnail '100x100' img -- alt="Hello" as th %}"""
|
||||||
|
render_tag(ttag)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignment_tag():
|
||||||
|
ttag = r"""{% thumbnail '100x100' img as th %}{{ th.url }}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
html = render_tag(ttag)
|
||||||
|
assert_not_equal(html, '')
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_dimension():
|
||||||
|
ttag = r"""{% thumbnail '100x' img as th %}{{ th.width }}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
html = render_tag(ttag)
|
||||||
|
eq_(html, '100')
|
||||||
|
|
||||||
|
|
||||||
|
def test_alternate_generator():
|
||||||
|
ttag = r"""{% thumbnail '1pxsq' '100x' img as th %}{{ th.width }}"""
|
||||||
|
clear_imagekit_cache()
|
||||||
|
html = render_tag(ttag)
|
||||||
|
eq_(html, '1')
|
||||||
106
tests/utils.py
Normal file
106
tests/utils.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from django.core.files import File
|
||||||
|
from django.template import Context, Template
|
||||||
|
from imagekit.cachefiles.backends import Simple, CacheFileState
|
||||||
|
from imagekit.conf import settings
|
||||||
|
from imagekit.lib import Image, StringIO
|
||||||
|
from imagekit.utils import get_cache
|
||||||
|
from nose.tools import assert_true, assert_false
|
||||||
|
import pickle
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from .models import Photo
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_file():
|
||||||
|
"""
|
||||||
|
See also:
|
||||||
|
|
||||||
|
http://en.wikipedia.org/wiki/Lenna
|
||||||
|
http://sipi.usc.edu/database/database.php?volume=misc&image=12
|
||||||
|
https://lintian.debian.org/tags/license-problem-non-free-img-lenna.html
|
||||||
|
https://github.com/libav/libav/commit/8895bf7b78650c0c21c88cec0484e138ec511a4b
|
||||||
|
"""
|
||||||
|
path = os.path.join(settings.MEDIA_ROOT, 'reference.png')
|
||||||
|
return open(path, 'r+b')
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_image_file():
|
||||||
|
file = NamedTemporaryFile()
|
||||||
|
file.write(get_image_file().read())
|
||||||
|
return file
|
||||||
|
|
||||||
|
|
||||||
|
def create_image():
|
||||||
|
return Image.open(get_image_file())
|
||||||
|
|
||||||
|
|
||||||
|
def create_instance(model_class, image_name):
|
||||||
|
instance = model_class()
|
||||||
|
img = File(get_image_file())
|
||||||
|
instance.original_image.save(image_name, img)
|
||||||
|
instance.save()
|
||||||
|
img.close()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def create_photo(name):
|
||||||
|
return create_instance(Photo, name)
|
||||||
|
|
||||||
|
|
||||||
|
def pickleback(obj):
|
||||||
|
pickled = StringIO()
|
||||||
|
pickle.dump(obj, pickled)
|
||||||
|
pickled.seek(0)
|
||||||
|
return pickle.load(pickled)
|
||||||
|
|
||||||
|
|
||||||
|
def render_tag(ttag):
|
||||||
|
img = get_image_file()
|
||||||
|
template = Template('{%% load imagekit %%}%s' % ttag)
|
||||||
|
context = Context({'img': img})
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
|
||||||
|
def get_html_attrs(ttag):
|
||||||
|
return BeautifulSoup(render_tag(ttag), features="html.parser").img.attrs
|
||||||
|
|
||||||
|
|
||||||
|
def assert_file_is_falsy(file):
|
||||||
|
assert_false(bool(file), 'File is not falsy')
|
||||||
|
|
||||||
|
|
||||||
|
def assert_file_is_truthy(file):
|
||||||
|
assert_true(bool(file), 'File is not truthy')
|
||||||
|
|
||||||
|
|
||||||
|
class DummyAsyncCacheFileBackend(Simple):
|
||||||
|
"""
|
||||||
|
A cache file backend meant to simulate async generation.
|
||||||
|
|
||||||
|
"""
|
||||||
|
is_async = True
|
||||||
|
|
||||||
|
def generate(self, file, force=False):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def clear_imagekit_cache():
|
||||||
|
cache = get_cache()
|
||||||
|
cache.clear()
|
||||||
|
# Clear IMAGEKIT_CACHEFILE_DIR
|
||||||
|
cache_dir = os.path.join(settings.MEDIA_ROOT, settings.IMAGEKIT_CACHEFILE_DIR)
|
||||||
|
if os.path.exists(cache_dir):
|
||||||
|
shutil.rmtree(cache_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_imagekit_test_files():
|
||||||
|
clear_imagekit_cache()
|
||||||
|
for fname in os.listdir(settings.MEDIA_ROOT):
|
||||||
|
if fname != 'reference.png':
|
||||||
|
path = os.path.join(settings.MEDIA_ROOT, fname)
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
os.remove(path)
|
||||||
51
tox.ini
51
tox.ini
|
|
@ -1,45 +1,18 @@
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
py27-django14, py27-django13, py27-django12,
|
py38-django{master,30,22,21,20,111},
|
||||||
py26-django14, py26-django13, py26-django12
|
py37-django{master,30,22,21,20,111},
|
||||||
|
py36-django{master,30,22,21,20,111},
|
||||||
|
py35-django{21,20,111},
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
changedir = tests
|
commands = python setup.py test
|
||||||
setenv = PYTHONPATH = {toxinidir}/tests
|
|
||||||
commands = django-admin.py test core --settings=settings
|
|
||||||
|
|
||||||
[testenv:py27-django14]
|
|
||||||
basepython = python2.7
|
|
||||||
deps =
|
deps =
|
||||||
Django>=1.4,<1.5
|
djangomaster: git+https://github.com/django/django.git@master#egg=Django
|
||||||
Pillow
|
django30: Django>=3.0,<3.1
|
||||||
|
django22: Django>=2.2,<3.0
|
||||||
[testenv:py27-django13]
|
django21: Django>=2.1,<2.2
|
||||||
basepython = python2.7
|
django20: Django>=2.0,<2.1
|
||||||
deps =
|
django111: Django>=1.11,<2.0
|
||||||
Django>=1.3,<1.4
|
django{21,20,111}: django-nose==1.4.5
|
||||||
Pillow
|
|
||||||
|
|
||||||
[testenv:py27-django12]
|
|
||||||
basepython = python2.7
|
|
||||||
deps =
|
|
||||||
Django>=1.2,<1.3
|
|
||||||
Pillow
|
|
||||||
|
|
||||||
[testenv:py26-django14]
|
|
||||||
basepython = python2.6
|
|
||||||
deps =
|
|
||||||
Django>=1.4,<1.5
|
|
||||||
Pillow
|
|
||||||
|
|
||||||
[testenv:py26-django13]
|
|
||||||
basepython = python2.6
|
|
||||||
deps =
|
|
||||||
Django>=1.3,<1.4
|
|
||||||
Pillow
|
|
||||||
|
|
||||||
[testenv:py26-django12]
|
|
||||||
basepython = python2.6
|
|
||||||
deps =
|
|
||||||
Django>=1.2,<1.3
|
|
||||||
Pillow
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue