diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 299a816..1ec0255 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,7 +193,7 @@ There are different sessions in nox related to building the docs: `docs`, `docs- * `docs`: this session builds the guide and opens it in your browser. ```bash - nox -e docs + nox -s docs ``` To see the guide built locally, open the file `_build/html/index.html` in your browser. @@ -201,7 +201,7 @@ There are different sessions in nox related to building the docs: `docs`, `docs- * `docs-test`: this session runs the tests for the guide. ```bash - nox -e docs-test + nox -s docs-test ``` If the tests fail, you will see an error message in your terminal. You need to fix the errors before submitting your pull request. @@ -209,7 +209,7 @@ There are different sessions in nox related to building the docs: `docs`, `docs- * `docs-live`: this session builds the guide and opens it in your browser with live reloading. ```bash - nox -e docs-live + nox -s docs-live ``` open the local version of the guide in your browser at ``localhost`` shown in the terminal. @@ -219,7 +219,7 @@ There are different sessions in nox related to building the docs: `docs`, `docs- Before submitting your pull request, make sure to run the tests and check the formatting of your code. ```bash -nox -e docs-test +nox -s docs-test ``` If the tests fail, you will see an error message in your terminal. You need to fix the errors before submitting your pull request. diff --git a/conf.py b/conf.py index d58ae23..d41d90c 100644 --- a/conf.py +++ b/conf.py @@ -137,8 +137,10 @@ "_build", "Thumbs.db", ".DS_Store", + ".direnv", ".github", ".nox", + ".venv", "README.md", "**/README.md", "styles/*", @@ -203,4 +205,4 @@ "codeautolink.match_block", "codeautolink.match_name", "codeautolink.failed_resolve", -] \ No newline at end of file +] diff --git a/index.md b/index.md index 242ba40..71b9223 100644 --- a/index.md +++ b/index.md @@ -34,13 +34,23 @@ * [Write Conditionals to redirect code](conditionals) * [Common Python exceptions](common-exceptions) - ::: :::: +::::{grid-item} +:::{card} [✿ Running Code ✿](running-code/intro) +:class-card: left-aligned + +* [Execute a Python Package](running-code/execute-package) +* [Execute a Python Script](running-code/execute-script) + +::: +:::: + ::::{grid-item} :::{card} [✿ Share Code ✿](publish-share-code/intro) :class-card: left-aligned @@ -86,6 +96,15 @@ Clean Code Optimize Code ::: +:::{toctree} +:hidden: +:caption: Running Code +:maxdepth: 2 + +Package Code +::: + + :::{toctree} :hidden: :caption: Share Code diff --git a/running-code/execute-package.md b/running-code/execute-package.md new file mode 100644 index 0000000..8accdb1 --- /dev/null +++ b/running-code/execute-package.md @@ -0,0 +1,208 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# How to Execute a Python Package + +In [How to Execute a Python Script](./execute-script) you learned about two primary ways to execute a stand-alone Python script. +There are two other ways to execute Python code from the command line, both of which work for code that has been formatted as a package. + +1. You can [**execute modules**](executable-modules) using their import name +2. You can [**execute packages**](executable-packages) using a `__main__.py` file +3. You can [**execute functions**](named-commands) named commands using project scripts + +(executable-modules)= +## 1. Executable modules + +In the [Pass your script to the Python command lesson](execute-script-pass-to-python) you learned that the `python` command can +be passed a file path for execution. Alternatively you can also pass the name of a module, exactly as would be used after an `import`. +In this case, Python will look up the module referenced in the +[currently active environment](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#create-and-use-virtual-environments), +and will execute it as a script. + +This execution mode is performed with the `-m` flag, as in `python -m site`. It can be used in place of a file +path, but cannot be used in combination with a path, as there can only be one executing module. + +:::{tip} +These commands both do the same thing, but the latter is easier to remember and much more portable, as it doesn't rely +on the local machine's file system details. + +```bash +python ./.venv/lib/python3.12/site-packages/pip/__main__.py +``` + +```bash +python -m pip +``` +::: + +### Further exploration + +On your own or in small groups: + +* Install the `my_program.py` module from the [last lesson](execute-script-launch-command) +* Try to get the same greeting as before using `python -m my_program`. + +**my_program.py** which prints out `✨ Hello from Python ✨` when executed as a script. + +```python +#!/usr/bin/env python + +def shiny_hello(): + print("\N{Sparkles} Hello from Python \N{Sparkles}") + +if __name__ == "__main__": + shiny_hello() +``` + +(executable-packages)= +## 2. Executable packages + +The `-m` flag as described above only works for Python modules (files), but does not work for Python (sub-)packages (directories). +This means that you cannot execute a command using only the name of your package when it is structured to use directories + +Once your package grows, the top-level name `my_program` turns into a directory. +(See [Python Package Structure](https://www.pyopensci.org/python-package-guide/package-structure-code/python-package-structure.html) +for when and how to create a package structure). + +``` +project/ +└── src/ + └── my_program/ + ├── __init__.py + └── greeting/ + ├── __init__.py + └── hello.py +``` + +However, the directory is not executable. + +```bash +python -m my_program +python: No module named my_program.__main__; 'my_program' is a package and cannot be directly executed +``` + +Initially, Python seems to tell you that the directory names, including your top-level package name, +cannot be directly executed. But the error message contains the hint that you need to make this run properly. + +[Earlier you learned](execute-script-name-eq-main) that `if __name__ == "__main__":` can protect parts of your +Python file from executing when it is imported, making that conditional change the file's behavior when used as +a script vs when used as a module. There is a very similar concept that can be applied to Python directories. + +You may already know that a directory that contains an [`__init__.py` module](https://www.pyopensci.org/python-package-guide/tutorials/installable-code.html#what-is-an-init-py-file) +becomes a valid `import` target and that whenever the directory is imported, the code in the `__init__.py` is executed. +There is another special file Python directories can contain: [`__main__.py` module](https://docs.python.org/3/library/__main__.html#module-__main__). +Any package that contains a `__main__.py` can be execued with `python -m` exactly like a Python module. When a +Python package (directory) is executed, the code in `__main__.py` is executed, as if it was the target of the `-m`. + +In this way a Python directory can segment its import behaviour from its command behaviour by using both +`__init__.py` and `__main__.py` in a very similar way to how a Python file segments this behaviour using +`if __name__ == "__main__":`. + +If we add to the earlier package structure, we can make the original execution command work. + +``` +project/ +└── src/ + └── my_program/ <-- directories with init and main can be imported or executed + ├── __init__.py + ├── __main__.py + └── greeting/ <-- directories with init an no main can be imported but not executed + ├── __init__.py + └── hello.py +``` + +```python +# project/src/my_program/__main__.py +from .greeting.hello import shiny_hello + +shiny_hello() +``` + +```bash +python -m my_program +# ✨ Hello from Python ✨ +``` + +:::{note} +The `__main__.py` file typically doesn't have an `if __name__ == "__main__":` conditional in it, as its execution +is already separated out from the rest of the package. +::: + +### Further exploration + +- Try to separate `my_program` into a package containing two files, including one `__main__.py` +- Try to get `python -m my_program` to work as before +- Does importing `my_program` still work [as before the separation](execute-script-name-eq-main)? + +```python +import my_program + +def guess_my_number(): + my_program.shiny_hello() + print("Was your number 42?") + +guess_my_number() +# ✨ Hello from Python ✨ +# Was your number 42? +``` + +:::{attention} +Don't forget to (re)install your package after creating this file! + +Unless you used an [editable install](https://www.pyopensci.org/python-package-guide/tutorials/installable-code.html#step-5-install-your-package-locally) +any additional files or changes you make won't be picked up by Python. +::: + +(named-commands)= +## 3. Named Commands + +The final way to make Python code executable directly from the command line is to include a special [entrypoint](https://packaging.python.org/en/latest/specifications/entry-points/) +into the package metadata. Entrypoints are a general purpose plug-in system for Python packages, but the +[`console_scripts`](https://packaging.python.org/en/latest/specifications/entry-points/#use-for-scripts) +entry is specifically targeted at creating executable commands on systems that install the package. + +These entrypoints and their commands are configured in your project's [`pyproject.toml`](https://www.pyopensci.org/python-package-guide/tutorials/pyproject-toml.html#what-is-a-pyproject-toml-file) file in the [`[project.scripts]`](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#creating-executable-scripts) table. + +```toml +[project.scripts] +shiny = "my_program.greetings.hello:shiny_hello" +``` + +In the above example `shiny` is the name of the command that will be made available in the shell after installation, +`my_program.greetings.hello` is the path of import required to access the necessary function (which may contain +`.subpackage` parts, depending on how you structured your package), and `:shiny_hello` is the function (proceeded with +`:`) that will be called, **without arguments**. + +A script target of `"my_program.greetings.hello:shiny_hello"` is logically equivilant to +```python +import my_program.greetings.hello + +my_program.greetings.hello.shiny_hello() +``` + +If this package was installed, the command would be made avalible in your shell + +```bash +shiny +# ✨ Hello from Python ✨ +``` + +### Further exploration + +On your own or in small groups: + +- List some advantages of making a Python package executable over providing a script entry point. +- List some disadvantages of making a Python package executable over providing a script entry point. +- Review the Pros section from [How to Execute a Python Script](execute-script-comparison) + - Do you see any similarities between executable packages and executable script files? + - Do you notice any similarities between entrypoint scripts and executable script files? diff --git a/running-code/execute-script.md b/running-code/execute-script.md new file mode 100644 index 0000000..7f7d411 --- /dev/null +++ b/running-code/execute-script.md @@ -0,0 +1,231 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# How to Execute a Python Script + +There are two primary ways to execute a Python script. + +1. You can [pass your script](execute-script-pass-to-python) to Python in your shell +2. You can [call your script](execute-script-launch-command) directly as a command in your shell + +(execute-script-pass-to-python)= +## 1. Pass your script to the Python command + +The first way to execute a Python script is to use the `python` command. The `python` command takes the name of a file and executes it from your shell, like this: + +```bash +python my_program.py +``` + +When Python reads a file using this approach, it executes each line of code from top to bottom. Lines within functions +or classes are not executed, unless that function is called or class is created outside of any other function or class definition. +This is similar, but not identical, to the behavior of copying this file and pasting it line-by-line into an interactive +Python shell (or a Jupyter notebook cell). + +```python +def report_error(): + print("An error has occured") + +print("\N{Sparkles} Hello from Python \N{Sparkles}") +``` + +Note that only one line is printed when this script is run. + +```bash +python my_program.py +# ✨ Hello from Python ✨ +``` + +The `report_error` function only runs if you call it directly in the file: + +```python +def report_error(): + print("An error has occured") + +print("\N{Sparkles} Hello from Python \N{Sparkles}") +report_error() +``` + +```bash +python my_program.py +# ✨ Hello from Python ✨ +# An error has occured +``` + +(execute-script-launch-command)= +## 2. Associate the script with a launch command + +The other way a Python script may be executed is to associate the file with a launch command. The way in which +this association is done depends on what operating system you are running. + +### Executing Python scripts on macOS / Linux / Non-Windows + +On Linux or Mac systems, the Python file can itself be turned into a command. By adding a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) +as the first line in any Python file, and by giving the file [executable permissions](https://docs.python.org/3/using/unix.html#miscellaneous) the +file can be directly invoked without a `python` command. + +```python +#!/usr/bin/env python +# The above line is a shebang, and can take the place of typing python on the command line +# This comment is below, because shebangs must be the first line! + +def shiny_hello(): + print("\N{Sparkles} Hello from Python \N{Sparkles}") + +shiny_hello() +``` + +```bash +my_program.py +# ✨ Hello from Python ✨ +``` + +:::{tip} +Shebangs are a feature of [POSIX](https://en.wikipedia.org/wiki/POSIX). POSIX represents some level of compatibility between systems. +Linux, macOS, all BSDs, and many other operating systems are fully- or mostly-POSIX compliant. + +Windows is not natively POSIX compliant. However, some "modes" inside of Windows are, such as [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) +(Windows Subsystem for Linux), gitbash, or some VSCode terminals. +::: + +### Executing Python scripts on Windows + +If your Windows machine has Python registered as the default application associated with `.py` files, then any Python +scripts can be run as commands. However, only one Python can be registered at a time, so all Python scripts run this +way will use the same Python environment. + +Additionally, most Windows Python installs come with the [Python Launcher](https://docs.python.org/3/using/windows.html#python-launcher-for-windows). +This launcher allows you to specify the version of Python to run and also can read shebang lines and emulate some of that behavior. +This allows you to reuse shebang lines between Linux, macOS, and Windows systems. However, on Windows, the command must still +be prefaced with another command (`py`). + +```bash +py my_program.py +# ✨ Hello from Python ✨ +``` + +:::{tip} +All Python files should end in a `.py`. The `.py` is required for Windows to associate a script with Python. In comparison, on Linux and Mac machines, `.py` is a recommended best practice; +the shebang (`#!`) associates the script execution with Python. + +Windows doesn’t rely on a shebang (`#!`) to determine how to execute a Python file. Instead, Python sees the first `#` and treats the line as a comment and ignores it! + +For reproducibility, which ensures that your code runs consistently across machines, you should always include the `.py` extension and the shebang (`#!`) in your Python scripts. +::: + +(execute-script-comparison)= +## Comparing passing files to Python vs the "shebang" (`#!`) execution approach + +There are pros and cons of using the two approaches above to execute Python files. + +### Pros of passing a file to `python`: +- It works with all files: The file path passed to Python doesn't have to end in a `.py` (not even on Windows) +- It works with more restrictive fiels: The file doesn't need execute permissions +- It's reproducible: This approach works on every system +- You are being explicit about what you expect to happen + +### Pros of associating your script with a launcher: +- The Python version used is associated with a specific Python installation +- That Python can be different that the Python installation currently active in a [`virtual-environment`](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#create-and-use-virtual-environments) +- You don't have to remember when using the command which Python installation the script should be associated with, or even that the script is written in Python + +(execute-script-name-eq-main)= +## Separating script from import behavior + +Sometimes a Python file that is useful to execute is also useful to import. You may want to use `shiny_hello` +in another Python file. But right now, the `my_program.py` does all its script behavior even when it is imported. Consider + +```python +import my_program + +def guess_my_number(): + my_program.shiny_hello() + print("Was your number 42?") + +guess_my_number() +# ✨ Hello from Python ✨ +# ✨ Hello from Python ✨ +# Was your number 42? +``` + +You may have expected it to print hello only once, but it printed the same line twice. This is because `my_program` is written to +_always_ call `shiny_hello`. Now `guess_my_number` also calls `shiny_hello`. Remember the contents of `my_program.py`. + +```python +#!/usr/bin/env python +# The above line is a shebang, and can take the place of typing python on the command line +# This comment is below, because shebangs must be the first line! + +def shiny_hello(): + print("\N{Sparkles} Hello from Python \N{Sparkles}") + +# NOTE: my_program runs the shiny_hello function whenever it is loaded +shiny_hello() +``` + +How can you make `my_program` only call `shiny_hello` when it is used as a script? +You may have already seen the answer, without realizing what it was doing. +`my_program` needs a conditional that checks if is is in "script mode" or "import mode" and that conditional is `if __name__ == "__main__":`. + +This conditional is most often included at the bottom of modules that are expected to be executed +directly. This conditional separates code that is intended to execute as part of a command or script from code that is intended to +execute as part of an import within another script. + +```python +#!/usr/bin/env python +# The above line is a shebang and can take the place of typing python on the command line +# This comment is below, because shebangs must be the first line! + +def shiny_hello(): + print("\N{Sparkles} Hello from Python \N{Sparkles}") + +# By calling shiny_hello within this conditional, it will only run when this file is run as a script. It will +# not run if you import it into another script. +if __name__ == "__main__": + shiny_hello() +``` + +```bash +my_program.py +# ✨ Hello from Python ✨ +``` + +```python +import my_program + +def guess_my_number(): + my_program.shiny_hello() + print("Was your number 42?") + +guess_my_number() +# ✨ Hello from Python ✨ +# Was your number 42? +``` + +:::{note} Why did that work? + +All Python modules (individual files) have a `__name__` attribute, which is usually the same as the name used to import the module. + +```python +import os +print(os.__name__) +# 'os' +``` + +This attribute is available within a module by using the variable `__name__`. So in the `os.py` module, `__name__` +also gives the value `'os'`. + +Importantly, this name is changed for the *first user-module* executed by Python. When you pass a file to +`python`, that is the first user-module executed. For this module, and only when it is the first, the `__name__` +is changed to the string `'__main__'`. This answers the question for every module used in a Python program, "am I the main module?". +::: diff --git a/running-code/intro.md b/running-code/intro.md new file mode 100644 index 0000000..53c9fe5 --- /dev/null +++ b/running-code/intro.md @@ -0,0 +1,28 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + ++++ {"editable": true, "slideshow": {"slide_type": ""}} + +# Running Code + +:::{toctree} +:hidden: +:caption: Package Code +:maxdepth: 2 + +Intro +Execute Script +Execute Package +::: + +