Skip to content

Commit a5b36fd

Browse files
Merge branch 'master' into python-web-applications
2 parents 8c49de3 + ad2a0bc commit a5b36fd

File tree

10 files changed

+350
-8
lines changed

10 files changed

+350
-8
lines changed

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
# Real Python Materials
22

3-
Bonus materials, exercises, and example projects for our [Python tutorials](https://realpython.com).
3+
Bonus materials, exercises, and example projects for Real Python's [Python tutorials](https://realpython.com).
44

55
Build Status: [![CircleCI](https://circleci.com/gh/realpython/materials.svg?style=svg)](https://circleci.com/gh/realpython/materials)
66

7-
## Running Code Style Checks
7+
## Got a Question?
8+
9+
The best way to get support for Real Python courses & articles and code in this repository is to join one of our [weekly Office Hours calls](https://realpython.com/office-hours/) or to ask your question in the [RP Community Slack](https://realpython.com/community/).
10+
11+
Due to time constraints we cannot provide 1:1 support via GitHub. See you on Slack or on the next Office Hours call 🙂
12+
13+
## Adding Source Code & Sample Projects to This Repo (RP Contributors)
14+
15+
### Running Code Style Checks
816

917
We use [flake8](http://flake8.pycqa.org/en/latest/) and [black](https://github.com/ambv/black) to ensure a consistent code style for all of our sample code in this repository.
1018

@@ -15,7 +23,7 @@ $ flake8
1523
$ black --check .
1624
```
1725

18-
## Running Python Code Formatter
26+
### Running Python Code Formatter
1927

2028
We're using a tool called [black](https://github.com/ambv/black) on this repo to ensure consistent formatting. On CI it runs in "check" mode to ensure any new files added to the repo are following PEP 8. If you see linter warnings that say something like "would reformat some_file.py" it means black disagrees with your formatting.
2129

nlp-sentiment-analysis/sentiment_analyzer.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,19 @@ def evaluate_model(tokenizer, textcat, test_data: list) -> dict:
7979
true_negatives = 0
8080
false_negatives = 1e-8
8181
for i, review in enumerate(textcat.pipe(reviews)):
82-
true_label = labels[i]
82+
true_label = labels[i]["cats"]
8383
for predicted_label, score in review.cats.items():
8484
# Every cats dictionary includes both labels, you can get all
8585
# the info you need with just the pos label
8686
if predicted_label == "neg":
8787
continue
88-
if score >= 0.5 and true_label == "pos":
88+
if score >= 0.5 and true_label["pos"]:
8989
true_positives += 1
90-
elif score >= 0.5 and true_label == "neg":
90+
elif score >= 0.5 and true_label["neg"]:
9191
false_positives += 1
92-
elif score < 0.5 and true_label == "neg":
92+
elif score < 0.5 and true_label["neg"]:
9393
true_negatives += 1
94-
elif score < 0.5 and true_label == "pos":
94+
elif score < 0.5 and true_label["pos"]:
9595
false_negatives += 1
9696
precision = true_positives / (true_positives + false_positives)
9797
recall = true_positives / (true_positives + false_negatives)

python-bitwise-operators/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Bitwise Operators in Python
2+
3+
Source code of the least-significant bit **steganography** example from the [Bitwise Operators in Python](https://realpython.com/python-bitwise-operators/) article.
4+
5+
## Installation
6+
7+
There are no external dependencies except for a Python interpreter.
8+
9+
## Running
10+
11+
Change directory to the current folder, where this `README.md` file is located, and then execute Python module:
12+
13+
```shell
14+
$ python -m stegano /path/to/bitmap (--encode /path/to/file | --decode | --erase)
15+
```
16+
17+
For example, to extract a secret file from the attached bitmap, type the following:
18+
19+
```shell
20+
$ python -m stegano example.bmp -d
21+
Extracted a secret file: podcast.mp4
22+
```
4.06 MB
Binary file not shown.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
Runnable module, can be executed as:
3+
$ python -m stegano
4+
"""
5+
6+
from .bitmap import Bitmap
7+
from .cli import CommandLineArguments, parse_args
8+
from .decoder import decode, DecodingError
9+
from .encoder import encode, EncodingError
10+
from .eraser import erase
11+
12+
13+
def main(args: CommandLineArguments) -> None:
14+
"""Entry point to the script."""
15+
with Bitmap(args.bitmap) as bitmap:
16+
if args.encode:
17+
encode(bitmap, args.encode)
18+
elif args.decode:
19+
decode(bitmap)
20+
elif args.erase:
21+
erase(bitmap)
22+
23+
24+
if __name__ == "__main__":
25+
try:
26+
main(parse_args())
27+
except (EncodingError, DecodingError) as ex:
28+
print(ex)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Bitmap read/write operations.
3+
"""
4+
5+
import pathlib
6+
from dataclasses import dataclass
7+
from itertools import islice
8+
from mmap import mmap, ACCESS_WRITE
9+
from struct import pack, unpack
10+
from typing import Any, Union, Iterator
11+
12+
13+
class Bitmap:
14+
"""High-level interface to a bitmap file."""
15+
16+
def __init__(self, path: pathlib.Path) -> None:
17+
self._file = path.open(mode="r+b")
18+
self._file_bytes = mmap(self._file.fileno(), 0, access=ACCESS_WRITE)
19+
self._header = Header.from_bytes(self._file_bytes[:50])
20+
21+
def __enter__(self) -> "Bitmap":
22+
return self
23+
24+
def __exit__(self, *args, **kwargs) -> None:
25+
self._file_bytes.close()
26+
self._file.close()
27+
28+
def __getattr__(self, name: str) -> Any:
29+
return getattr(self._header, name)
30+
31+
def __getitem__(self, offset: Union[int, slice]) -> Union[int, bytes]:
32+
return self._file_bytes[offset]
33+
34+
def __setitem__(
35+
self, offset: Union[int, slice], value: Union[int, bytes]
36+
) -> None:
37+
self._file_bytes[offset] = value
38+
39+
@property
40+
def max_bytes(self) -> int:
41+
"""The maximum number of bytes the bitmap can hide."""
42+
return self.width * self.height * 3
43+
44+
@property
45+
def byte_offsets(self) -> Iterator[int]:
46+
"""Return an iterator over byte offsets (skip the padding)."""
47+
48+
start_index = self.pixels_offset
49+
end_index = self.pixels_offset + self.pixel_size_bytes
50+
scanline_bytes = self.pixel_size_bytes // self.height
51+
52+
for scanline in range(start_index, end_index, scanline_bytes):
53+
yield from range(scanline, scanline + self.width * 3)
54+
55+
@property
56+
def byte_slices(self) -> Iterator[slice]:
57+
"""Generator iterator of 8-byte long slices."""
58+
for byte_index in islice(self.byte_offsets, 0, self.max_bytes, 8):
59+
yield slice(byte_index, byte_index + 8)
60+
61+
@property
62+
def reserved_field(self) -> int:
63+
"""Return a little-endian 32-bit unsigned integer."""
64+
return unsigned_int(self._file_bytes, 0x06)
65+
66+
@reserved_field.setter
67+
def reserved_field(self, value: int) -> None:
68+
"""Store a little-endian 32-bit unsigned integer."""
69+
self._file_bytes.seek(0x06)
70+
self._file_bytes.write(pack("<I", value))
71+
72+
73+
@dataclass
74+
class Header:
75+
"""Bitmap metadata from the file header."""
76+
77+
signature: bytes
78+
file_size_bytes: int
79+
pixel_size_bytes: int
80+
pixels_offset: int
81+
width: int
82+
height: int
83+
bit_depth: int
84+
compressed: bool
85+
has_palette: bool
86+
87+
def __post_init__(self):
88+
assert self.signature == b"BM", "Unknown file signature"
89+
assert not self.compressed, "Compression unsupported"
90+
assert not self.has_palette, "Color palette unsupported"
91+
assert self.bit_depth == 24, "Only 24-bit depth supported"
92+
93+
@staticmethod
94+
def from_bytes(data: bytes) -> "Header":
95+
"""Factory method to deserialize the header from bytes."""
96+
return Header(
97+
signature=data[0x00:2],
98+
file_size_bytes=unsigned_int(data, 0x02),
99+
pixels_offset=unsigned_int(data, 0x0A),
100+
width=unsigned_int(data, 0x12),
101+
height=unsigned_int(data, 0x16),
102+
bit_depth=unsigned_short(data, 0x1C),
103+
compressed=unsigned_int(data, 0x1E) != 0,
104+
has_palette=unsigned_int(data, 0x2E) != 0,
105+
pixel_size_bytes=unsigned_int(data, 0x22),
106+
)
107+
108+
109+
def unsigned_int(data: Union[bytes, mmap], offset: int) -> int:
110+
"""Read a little-endian 32-bit unsigned integer."""
111+
return unpack("<I", data[offset : offset + 4])[0]
112+
113+
114+
def unsigned_short(data: Union[bytes, mmap], offset: int) -> int:
115+
"""Read a little-endian 16-bit unsigned integer."""
116+
return unpack("<H", data[offset : offset + 2])[0]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
Command line arguments parsing.
3+
"""
4+
5+
import argparse
6+
import pathlib
7+
from dataclasses import dataclass
8+
from typing import Optional
9+
10+
11+
@dataclass
12+
class CommandLineArguments:
13+
"""Parsed command line arguments."""
14+
15+
bitmap: pathlib.Path
16+
encode: Optional[pathlib.Path]
17+
decode: bool
18+
erase: bool
19+
20+
21+
def parse_args() -> CommandLineArguments:
22+
"""Parse command line arguments."""
23+
24+
parser = argparse.ArgumentParser()
25+
parser.add_argument("bitmap", type=path_factory)
26+
27+
modes = parser.add_mutually_exclusive_group()
28+
modes.add_argument("--encode", "-e", metavar="file", type=path_factory)
29+
modes.add_argument("--decode", "-d", action="store_true")
30+
modes.add_argument("--erase", "-x", action="store_true")
31+
32+
args = parser.parse_args()
33+
34+
if not any([args.encode, args.decode, args.erase]):
35+
parser.error("Mode required: --encode file | --decode | --erase")
36+
37+
return CommandLineArguments(**vars(args))
38+
39+
40+
def path_factory(argument: str) -> pathlib.Path:
41+
"""Convert the argument to a path instance."""
42+
43+
path = pathlib.Path(argument)
44+
45+
if not path.exists():
46+
raise argparse.ArgumentTypeError("file doesn't exist")
47+
48+
if not path.is_file():
49+
raise argparse.ArgumentTypeError("must be a file")
50+
51+
return path
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
Secret file decoder.
3+
"""
4+
5+
from itertools import islice, takewhile
6+
from pathlib import Path
7+
from typing import Iterator
8+
9+
from .bitmap import Bitmap
10+
11+
12+
class DecodingError(Exception):
13+
pass
14+
15+
16+
def decode(bitmap: Bitmap) -> None:
17+
"""Extract a secret file from the bitmap."""
18+
19+
if bitmap.reserved_field <= 0:
20+
raise DecodingError("Secret file not found in the bitmap")
21+
22+
iterator = secret_bytes(bitmap)
23+
24+
filename = "".join(map(chr, takewhile(lambda x: x != 0, iterator)))
25+
with Path(filename).open(mode="wb") as file:
26+
file.write(bytes(islice(iterator, bitmap.reserved_field)))
27+
28+
print(f"Extracted a secret file: {filename}")
29+
30+
31+
def secret_bytes(bitmap) -> Iterator[int]:
32+
"""Return an iterator over secret bytes."""
33+
for eight_bytes in bitmap.byte_slices:
34+
yield sum(
35+
[
36+
(byte & 1) << (7 - i)
37+
for i, byte in enumerate(bitmap[eight_bytes])
38+
]
39+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
Secret file encoder.
3+
"""
4+
5+
import pathlib
6+
from typing import Iterator
7+
8+
from .bitmap import Bitmap
9+
10+
11+
class EncodingError(Exception):
12+
pass
13+
14+
15+
class SecretFile:
16+
"""Convenience class for serializing secret data."""
17+
18+
def __init__(self, path: pathlib.Path):
19+
self.path = path
20+
self.filename = path.name.encode("utf-8") + b"\x00"
21+
self.size_bytes = path.stat().st_size
22+
23+
@property
24+
def num_secret_bytes(self) -> int:
25+
"""Total number of bytes including the null-terminated string."""
26+
return len(self.filename) + self.size_bytes
27+
28+
@property
29+
def secret_bytes(self) -> Iterator[int]:
30+
"""Null-terminated name followed by the file content."""
31+
yield from self.filename
32+
with self.path.open(mode="rb") as file:
33+
yield from file.read()
34+
35+
36+
def encode(bitmap: Bitmap, path: pathlib.Path) -> None:
37+
"""Embed a secret file in the bitmap."""
38+
39+
file = SecretFile(path)
40+
41+
if file.num_secret_bytes > bitmap.max_bytes:
42+
raise EncodingError("Not enough pixels to embed a secret file")
43+
44+
bitmap.reserved_field = file.size_bytes
45+
for secret_byte, eight_bytes in zip(file.secret_bytes, bitmap.byte_slices):
46+
secret_bits = [(secret_byte >> i) & 1 for i in reversed(range(8))]
47+
bitmap[eight_bytes] = bytes(
48+
[
49+
byte | 1 if bit else byte & ~1
50+
for byte, bit in zip(bitmap[eight_bytes], secret_bits)
51+
]
52+
)
53+
54+
print("Secret file was embedded in the bitmap")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
Secret file eraser.
3+
"""
4+
5+
from itertools import islice
6+
from random import random
7+
8+
from .bitmap import Bitmap
9+
10+
11+
def erase(bitmap: Bitmap) -> None:
12+
"""Scramble a previously hidden data."""
13+
if bitmap.reserved_field > 0:
14+
for byte_offset in islice(bitmap.byte_offsets, bitmap.reserved_field):
15+
bitmap[byte_offset] = randomize_lsb(bitmap[byte_offset])
16+
bitmap.reserved_field = 0
17+
print("Erased a secret file from the bitmap")
18+
else:
19+
print("Secret file not found in the bitmap")
20+
21+
22+
def randomize_lsb(value: int) -> int:
23+
"""Set a random bit on the least-significant position."""
24+
return value & ~1 if random() < 0.5 else value | 1

0 commit comments

Comments
 (0)