Skip to main content

Command Palette

Search for a command to run...

Building a Domain Security Analyzer — Understanding Python Packages

Published
8 min read

In the last post, we completed the HTTP security headers analysis. Our tool works, but everything lives in one giant file. Before we restructure the project, let's understand how Python organizes code into packages.

Why We Need a New Structure

Open your current analyzer.py. It probably has:

  • DNS lookup functions

  • SSL checking functions

  • HTTP header functions

  • All the print statements

  • The CLI logic

Everything tangled together. This works for a script, but what happens when we want to add a web interface? Flask can't use print statements — it needs data returned as dictionaries or JSON. We'd have to rewrite everything.

The solution is to separate our code into a package — a folder containing related Python files that work together.


Today's Goal

By the end of this post, you'll understand:

  1. What a Python package is

  2. Why we need the scanner folder

  3. What __init__.py does

  4. Every line of the code inside __init__.py


Part 1: What is a Python Package?

A package is just a folder with Python files. But not every folder is a package.

Regular folder:

my_folder/
├── file1.py
└── file2.py

Python package:

my_package/
├── __init__.py    ← This makes it a package
├── file1.py
└── file2.py

The difference is __init__.py. This file tells Python: "This folder is a package. You can import from it."


Part 2: Why Create a scanner Folder?

We're going to have multiple scanner files:

  • One for DNS checks

  • One for SSL checks

  • One for HTTP header checks

  • One main file that coordinates them all

Instead of dumping all these in the src folder, we group them:

src/
├── scanner/           ← All scanning code lives here
│   ├── __init__.py
│   ├── dns_scanner.py
│   ├── ssl_scanner.py
│   ├── headers_scanner.py
│   └── scanner.py
└── analyzer.py        ← Entry point that uses the scanner package

This organization gives us:

  1. Clear boundaries — All scanning logic is in one place

  2. Clean importsfrom scanner import Scanner instead of scattered imports

  3. Easy navigation — Need to fix SSL? Look in the scanner folder

  4. Room to grow — Add more scanners later without cluttering the main folder


Part 3: What Does __init__.py Do?

When you write from scanner import Scanner in analyzer.py, Python does this:

  1. Finds the scanner folder in the same directory

  2. Runs __init__.py inside that folder

  3. Looks for Scanner in what __init__.py made available

The __init__.py is like a reception desk. You don't need to know which specific office (file) has what you need — you ask at reception, and they direct you.


Part 4: The Code Inside __init__.py

Here's the complete file:

"""Scanner package for domain security analysis."""

from .scanner import Scanner

__all__ = ['Scanner']

Four lines. But there's a lot happening. Let's break it down.


Line 1: The Docstring

"""Scanner package for domain security analysis."""

This describes what the package does. When someone types help(scanner) in Python, they'll see this text. It's documentation.

Docstrings use triple quotes (""") so they can span multiple lines if needed. Even though this one is short, triple quotes are convention for documentation.


Line 2: The Import

from .scanner import Scanner

This line imports the Scanner class from scanner.py into __init__.py. Let's break down the syntax.

Understanding the dot (.)

The dot means "look in the current folder." When Python sees .scanner, it looks for scanner.py inside the same folder where __init__.py lives.

scanner/
├── __init__.py      ← We're here
├── scanner.py       ← .scanner refers to this
└── dns_scanner.py   ← .dns_scanner would refer to this

This is called a relative import — relative to the current location. The dot keeps the search local to our package folder.

What we're importing

Inside scanner.py, there's a class called Scanner:

class Scanner:
    def __init__(self, domain):
        self.domain = domain

    def scan(self):
        # ... scanning logic

The line from .scanner import Scanner grabs that class and makes it available in __init__.py.

Why do this?

Without this import in __init__.py, users would have to write:

from scanner.scanner import Scanner

That's awkward — scanner.scanner is redundant. The first scanner is the folder, the second scanner is the file inside it. By importing Scanner into __init__.py, we let users write:

from scanner import Scanner

The scanner here is the folder. The Scanner is the class we made available through __init__.py. Much cleaner.


Line 3: The Export List

__all__ = ['Scanner']

This line controls what gets exported when someone writes:

from scanner import *

The asterisk (*) means "import everything." But what's "everything"? That's what __all__ defines.

The 'Scanner' in the list is a string that matches the class we imported on the previous line. We imported the Scanner class, so we can include 'Scanner' in __all__.

If we had imported multiple things:

from .scanner import Scanner
from .dns_scanner import get_a_records
from .ssl_scanner import get_ssl_info

__all__ = ['Scanner', 'get_a_records', 'get_ssl_info']

Each string in __all__ matches something we imported or defined in __init__.py.

Is __all__ required?

No. The package works without it. But it's good practice for two reasons:

  1. Documentation — Anyone reading the code immediately knows what's meant to be public

  2. Control — Prevents accidental exposure of internal helpers


How It All Connects

Let's trace what happens when analyzer.py runs from scanner import Scanner:

src/
├── scanner/
│   ├── __init__.py
│   └── scanner.py
└── analyzer.py          ← You run this file

Step 1: Python sees from scanner and looks for a scanner folder in the same directory as analyzer.py.

Step 2: Python finds scanner/ folder and runs scanner/__init__.py.

Step 3: Inside __init__.py, the line from .scanner import Scanner runs. Python looks for scanner.py in the same folder and grabs the Scanner class.

Step 4: Now Scanner is available in __init__.py. Python gives it to analyzer.py.

Step 5: analyzer.py can now use Scanner directly.

┌─────────────────────────────────────────────┐
│              analyzer.py                     │
│                                              │
│   from scanner import Scanner               │
│                    │                         │
│   # Scanner class is now available          │
│   s = Scanner("github.com")                 │
│                                              │
└────────────────────┼─────────────────────────┘
                     │
        "Give me Scanner from the scanner folder"
                     │
                     ▼
┌─────────────────────────────────────────────┐
│           scanner/__init__.py               │
│                                              │
│   from .scanner import Scanner              │
│                    │                         │
│   # Scanner class is now here               │
│   # Ready to hand out                       │
│                                              │
└────────────────────┼─────────────────────────┘
                     │
        "Get Scanner class from scanner.py in this folder"
                     │
                     ▼
┌─────────────────────────────────────────────┐
│           scanner/scanner.py                │
│                                              │
│   class Scanner:                            │
│       def __init__(self, domain):           │
│           self.domain = domain              │
│                                              │
│       def scan(self):                       │
│           return results                    │
│                                              │
└─────────────────────────────────────────────┘

The __init__.py acts as a middleman — it fetches Scanner from scanner.py and makes it available to anyone importing from the scanner folder.


Why Not Just Use One File?

You might wonder: "Why not keep everything in one scanner.py file?"

Right now, our scanner code isn't that big. But imagine:

  • DNS scanning: 150 lines

  • SSL scanning: 100 lines

  • Header scanning: 80 lines

  • Main scanner logic: 50 lines

  • Future: WHOIS lookup, subdomain scanning, port scanning...

Soon you have a 500+ line file. Finding anything becomes a hunt. Making changes risks breaking unrelated code.

Separate files mean:

  1. Easier navigation — Need to fix SSL checking? Open ssl_scanner.py. Don't wade through DNS code.

  2. Isolated changes — Modify dns_scanner.py without touching SSL logic.

  3. Team collaboration — Two people can work on different scanners without merge conflicts.

  4. Testing — Test DNS functions without loading SSL dependencies.

The __init__.py ties them together into one clean interface.


What Happens Without __init__.py?

If a folder doesn't have __init__.py, you can't import from it using the clean from folder import something syntax.

src/
├── scanner/           ← No __init__.py
│   └── scanner.py
└── analyzer.py

If you try from scanner import Scanner in analyzer.py, Python won't recognize scanner as a package.

There's a workaround. You can import the file directly by its path:

from scanner.scanner import Scanner

This works because Python can find scanner/scanner.py as a file path. But it's clunky — you have to know the exact file structure. And you lose the ability to create clean shortcuts.

Bottom line: Always include __init__.py. Even an empty one makes the folder a proper package.


Alternative: An Empty __init__.py

You might see tutorials with an empty __init__.py:

# __init__.py is empty

This is valid. It makes the folder a package. But then users must use longer imports:

from scanner.scanner import Scanner
from scanner.dns_scanner import get_a_records

By putting imports in __init__.py, we create shortcuts:

from scanner import Scanner  # Much nicer

Common Questions

Why is the file named __init__?

The double underscores (called "dunder" for "double underscore") mark special Python files. __init__ specifically means "initialization" — this file runs when the package is first imported. It initializes the package.

Other dunder files you might encounter:

  • __main__.py — Runs when you execute the package directly

  • __pycache__/ — Folder where Python stores compiled bytecode

Can __init__.py have more code?

Yes. Some packages have hundreds of lines in __init__.py. You can define functions, classes, constants — anything. But keeping it simple (just imports and __all__) is cleaner for most projects.

What if I forget __init__.py?

In Python 3.3+, folders without __init__.py become "namespace packages." They work differently and are meant for special cases like splitting a package across multiple directories. For normal projects, always include __init__.py.


What We've Learned

  1. A package is a folder with __init__.py — This file tells Python the folder is importable

  2. The dot means "current folder"from .scanner looks for scanner.py in the same folder as __init__.py

  3. __init__.py is a middleman — It imports from internal files and makes things available to outside code

  4. __all__ controls star imports — Lists what from package import * includes

  5. Packages organize growing code — Separate files for separate concerns, unified interface through __init__.py


What's Next

Now that you understand why we need a scanner package and what __init__.py does, in the next post we'll create the actual scanner files — moving our DNS, SSL, and header checking code into their own organized modules.


This is Part 5 of the Domain Security Analyzer series.

More from this blog

Figure it Out

15 posts