# Working with Numbers

This section discusses the operations on numbers in the Python standard library, such as generating random numbers, random sequences, encryption, etc.

### Working with random numbers

Computers use pseudo-random number generators (PRNGs) to generate random numbers. It uses random seed value to derive a random number, typically used for statistical purposes, not to be used for security purposes. CSPRNGs (cryptographic secure pseudo-random number generators) are cryptographically secure PRNGs. CSRNGs are intended to be used for password generation, key generation, and other cryptographic purposes.

Python uses the `random` module to generate random numbers.

```python
import random
print(random.random())
```

To implement a coin toss program:

```python
import random
for i in range(10):
    print(random.choice(['heads', 'tails']))
```

We can specify the range of random numbers to be generated:

```python
import random
print(random.randint(1, 10)) # to generate a random integer between 1 and 10
print(random.uniform(1, 10)) # to generate a random float between 1 and 10
print(random.randrange(1, 10, 2)) # to generate a random integer between 1 and 10, but skipping even numbers
```

We can use `random.seed()` to set the seed value. For a fixed seed value, we can generate the same sequence of random numbers.

```python
import random
random.seed(1)
print(random.random())
```

No matter how many times we run the above program, the random number sequence will be the same. It is always `0.13436424411240122`. This is useful for reproducing a result, but it is not a good idea for security purposes.

### Random sequences operations

We can use `choice` function to pick a random element from a sequence, for example:

```python
moves = ['rock', 'paper', 'scissors']
print(random.choice(moves))
```

The output is a random element from the sequence, for example `'paper'`. Python 3.6 can use `choices` (with an s) to select multiple elements, and can use weights to specify the probability of each element, for example:

```python
weights = [1, 2, 3] # weights for each element, probabilities are 1/6, 2/6, 3/6
print(random.choices(moves, weights, k=5)) # pick 5 elements
```

Elements will be selected with replacement by `choices`, but not by `sample`. This means the output could contain duplicate elements when using `choices`, but not when using `sample`.

```python
print(random.sample(moves, 2))
```

`shuffle` is used to shuffle a sequence, for example:

```python
random.shuffle(moves)
print(moves)
```

If the sequence is immutable, we can first use `sample` to pick some elements, and then use `shuffle` to shuffle the elements.

```python
import string
result = random.sample(string.ascii_lowercase, k=len(string.ascii_lowercase))
random.shuffle(result)
print(result)
```

The output is a random permutation of the elements in the sequence.

### Cryptographic random operations

Starting from Python 3.6, there is a `secrets` module that provides secure random number generators, such as generating passwords, keys, and so on. `os` module provides `urandom` function to read random bytes from the `/dev/urandom` device, which is the base of `secrets` module. The following code is to generate 16 bytes of random data using `os` module's `urandom` function:

```python
import os
result = os.urandom(16)
print([hex(b) for b in result]) # convert bytes to hexadecimal
```

We don't need to use `urandom` because `secrets` provides higher-level random number generators, such as `secrets.choice` to pick a random element from a sequence, for example:

```python
import secrets
print(secrets.choice(moves))
```

We can use `secrets.token_bytes` to generate a random byte sequence of a specified length:

```python
import secrets
print(secrets.token_bytes(16))
```

we can use `secrets.token_hex` to generate a random hexadecimal string of a specified length:

```python
import secrets
print(secrets.token_hex(16))
```

### Temporary paswords and URLs

By using `secrets.choice` function, we can pick a random element from a sequence. It can be used to generate a temporary password.

```python
import secrets
import string
results = "".join(secrets.choice(string.ascii_letters + string.digits) for i in range(8)) # generate a random password of 8 characters, including letters and digits
print(results)
```

The output is a random password of 8 characters, including letters and digits.

To enforce 1 number and 1 uppercase letter, use:

```python
import secrets
import string
potentialChars = string.ascii_letters + string.digits + "!@#$%^&*()"
while True:
    results = "".join(secrets.choice(potentialChars) for i in range(8))
    if (any(c.isdigit() for c in results) and any(c.isupper() for c in results)):
        print(results)
        break
```

We can see the output is a random 8-character string with at least one number and one uppercase letter.

We can use `secrets.token_urlsafe` to generate a temporary, hard-to-guess URL-safe string:

```python
result_url = "https://my.example.com/?reset=" + secrets.token_urlsafe(16)
print(result_url)
```

The output is a random URL-safe string like `https://my.example.com/reset=?DyFTgxUM87r_TVoWTG4UZg`. This function can be used to generate a temporary and safe url to user for a variety of purposes (e.g. resetting a password, sending an email, etc.).

### Generating unique identifiers

We can use `uuid` module to generate unique identifiers. UUID stands for Universally Unique Identifier. UUIDs are 128-bit strings, which are guaranteed to be unique across all UUIDs.

`uuid.uuid4` generates a pseudo-random UUID. The 4 represents the version number.

```python
import uuid
result = uuid.uuid4()
print(result)
print(result.hex)
print(result.urn)
```

The output is something like

```
54885f36-a0a9-46a2-a745-1ff85a76c03b
54885f36a0a946a2a7451ff85a76c03b
urn:uuid:54885f36-a0a9-46a2-a745-1ff85a76c03b
```

`hex` method returns the hexadecimal representation of the UUID. The dashes are removed.

URN stands for Uniform Resource Name. It is a standard format for identifying resources.

`uuid.uuid5` generates a UUID using a namespace and a name value. Here is an example:

```python
import uuid
result = uuid.uuid5(uuid.NAMESPACE_DNS, "python.org")
print(result)
print(result.hex)
print(result.urn)
```

The output is something like

```
886313e1-3b8a-5372-9b90-0c9aee199e5d
886313e13b8a53729b900c9aee199e5d
urn:uuid:886313e1-3b8a-5372-9b90-0c9aee199e5d
```

This version of UUID is based on the MD5 hash function. It is a 128-bit hash value. UUID5 is not truly random. If we ran the same code twice, the output would be the same.

### Statistics functions

Python standard library provides a set of functions to compute statistics. But if you are going to do some serious statistics, you should use `numpy` or `pandas` module.

`statistics` module provides functions to compute statistics, such as `mean`, `median`, `mode`, `stdev`, `variance`, etc.

An example of using `statistics` module:

```python
import statistics
import random
random_list = [random.randint(1, 100) for i in range(10)]
print(statistics.mean(random_list))
print(statistics.median(random_list))
print(statistics.median_low(random_list))
print(statistics.median_high(random_list))
print(statistics.mode(random_list))
print(statistics.stdev(random_list))
print(statistics.variance(random_list))
```

The `mean` and `median` will return the mean and median of a random generated list. `median_low` and `median_high` are the lower and upper bounds of the median. Their returned values are guaranteed to be within the list. The `mode` function returns the most frequent value in the list. If there are multiple modes, it returns the one that is foremost in the list. The `stdev` and `variance` functions return the standard deviation and variance of the list.
