Skip to content

Commit 69dfbda

Browse files
authored
Merge pull request #61 from ARRC-Rocket/develop
REL: v0.1.0
2 parents 7ebe8ae + 2564637 commit 69dfbda

57 files changed

Lines changed: 26891 additions & 287 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/pull_request_template.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
## What this changes
2+
3+
<!-- One or two sentences. The title of this pull request is what appears in the
4+
release notes, so write it as the line you would want a competitor to read. -->
5+
6+
## Why
7+
8+
<!-- The problem, not the patch. Link the issue if there is one: Closes #123 -->
9+
10+
## How it was checked
11+
12+
<!-- What you ran, and what it said. The CI commands are in CONTRIBUTING.md. -->
13+
14+
---
15+
16+
- [ ] A label is set, so this lands in the right release-notes section
17+
(`breaking-change`, `enhancement`, `bug`, `documentation`)
18+
- [ ] Anything a competitor would notice is in `CHANGELOG.md` under `Unreleased`

.github/release.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Categories for the release notes GitHub generates from merged pull requests.
2+
#
3+
# The generator already writes each entry as "<title> by @author in #<pr>", plus a
4+
# New Contributors section and a compare link, which is where the contributor
5+
# names come from. This file only groups those entries by label, the way
6+
# Kubernetes groups its notes under "Changes by Kind".
7+
#
8+
# Labels drive the grouping, so a pull request lands in "Other Changes" unless it
9+
# carries one. Everything below except breaking-change is a label this repository
10+
# already has.
11+
changelog:
12+
exclude:
13+
labels:
14+
- duplicate
15+
- invalid
16+
- question
17+
- wontfix
18+
categories:
19+
# Competitors' agents break when the observation space, the action space, the
20+
# scoring or the submission format changes, so this goes first and on its own.
21+
- title: Breaking Changes
22+
labels:
23+
- breaking-change
24+
- title: Features
25+
labels:
26+
- enhancement
27+
- title: Bug Fixes
28+
labels:
29+
- bug
30+
- title: Documentation
31+
labels:
32+
- documentation
33+
- title: Other Changes
34+
labels:
35+
- "*"

.github/workflows/ci.yml

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@ name: CI
22

33
on:
44
push:
5-
branches: [main]
5+
# develop as well as main. Work reaches the integration branch by merge and
6+
# occasionally by direct push, and neither used to run anything here, so a
7+
# regression could sit on develop until some later pull request inherited it.
8+
branches: [main, develop]
69
pull_request:
710
workflow_dispatch:
811

912
concurrency:
1013
group: ci-${{ github.ref }}
1114
cancel-in-progress: true
1215

16+
# Nothing here writes back to the repository. Without a declaration the
17+
# workflow token carries whatever the repository default grants, which is
18+
# usually write, and a checkout plus a third-party dependency install is the
19+
# wrong place to be holding it. Jobs that need more can ask for it locally.
20+
permissions:
21+
contents: read
22+
1323
jobs:
1424
lint:
1525
name: Lint and format
@@ -24,19 +34,29 @@ jobs:
2434
python-version: "3.14"
2535

2636
- name: Install ruff
27-
run: python -m pip install ruff
37+
run: python -m pip install "ruff==0.15.20"
2838

2939
- name: Lint
30-
run: ruff check BalloonPoppingGymEnv/ tests/
40+
run: ruff check BalloonPoppingGymEnv/ tests/ doc/examples/
3141

3242
- name: Format check
33-
run: ruff format --check BalloonPoppingGymEnv/ tests/
43+
run: ruff format --check BalloonPoppingGymEnv/ tests/ doc/examples/
3444

3545
test:
36-
name: Tests and coverage
46+
name: Tests and coverage (Python ${{ matrix.python-version }})
3747
runs-on: ubuntu-latest
48+
strategy:
49+
# Competitors run this on their own machines, and pyproject.toml claims
50+
# >=3.10, so the floor is tested rather than only asserted. ActiveRocketPy
51+
# already runs its own CI on 3.10.
52+
fail-fast: false
53+
matrix:
54+
python-version: ["3.10", "3.14"]
3855
env:
3956
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
57+
# Run the opt-in slow regression tests (e.g. the scenario #1 Monte Carlo
58+
# golden master) in CI; they are skipped by default for fast local runs.
59+
BPC_RUN_SLOW_TESTS: "1"
4060
steps:
4161
- name: Check out repository
4262
uses: actions/checkout@v6
@@ -46,10 +66,17 @@ jobs:
4666
- name: Set up Python
4767
uses: actions/setup-python@v6
4868
with:
49-
python-version: "3.14"
69+
python-version: ${{ matrix.python-version }}
5070
cache: pip
5171
cache-dependency-path: requirements-dev.txt
5272

73+
# CI installs with pip, so nothing else would notice uv.lock going stale
74+
# against the ActiveRocketPy submodule. This fails when it does.
75+
- name: Check the uv lockfile is current
76+
run: |
77+
python -m pip install "uv==0.11.14"
78+
uv lock --check
79+
5380
- name: Install dependencies
5481
run: |
5582
python -m pip install --upgrade pip
@@ -62,13 +89,13 @@ jobs:
6289
if: ${{ !cancelled() }}
6390
uses: actions/upload-artifact@v7
6491
with:
65-
name: coverage-xml
92+
name: coverage-xml-py${{ matrix.python-version }}
6693
path: coverage.xml
6794
if-no-files-found: warn
6895

6996
# Skipped until a CODECOV_TOKEN repository secret is configured.
7097
- name: Upload coverage to Codecov
71-
if: ${{ env.CODECOV_TOKEN != '' }}
98+
if: ${{ env.CODECOV_TOKEN != '' && matrix.python-version == '3.14' }}
7299
uses: codecov/codecov-action@v5
73100
with:
74101
token: ${{ secrets.CODECOV_TOKEN }}

ActiveRocketPy

Submodule ActiveRocketPy updated 211 files

BalloonPoppingGymEnv/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
import logging
2+
13
from gymnasium.envs.registration import register
24

35
register(
46
id="BalloonPoppingGymEnv/BalloonPoppingEnv-v0",
57
entry_point="BalloonPoppingGymEnv.envs:BalloonPoppingEnv",
68
)
9+
10+
# A library emits and does not print. Without a handler, records at WARNING and
11+
# above reach logging's last-resort handler and land on stderr; this package has
12+
# none today, and a NullHandler keeps that true if one is ever added. Entry
13+
# points call console_logging.configure_console_logging to opt in.
14+
logging.getLogger(__name__).addHandler(logging.NullHandler())
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Sending this package's diagnostics to a console, for the entry points.
2+
3+
A library does not configure logging for its process, so the package's modules
4+
only ever call ``logger.info``. Something has to turn that into output, and that
5+
something is whoever owns the process: the CLI, the example scripts, the
6+
notebook. Before this existed only the CLI did it, so calling
7+
``evaluate_scenario`` from anywhere else printed nothing at all.
8+
"""
9+
10+
import logging
11+
import sys
12+
13+
PACKAGE_LOGGER_NAME = "BalloonPoppingGymEnv"
14+
# Marks the handler this module owns, so repeat calls replace that one and leave
15+
# anything the host installed alone.
16+
CONSOLE_HANDLER_NAME = "BalloonPoppingGymEnv.console"
17+
18+
19+
def configure_console_logging(level=logging.INFO, stream=None):
20+
"""Print this package's log records as plain lines, and nothing else's.
21+
22+
Scoped to the package logger rather than the root one. ``basicConfig`` on the
23+
root logger opens the same stream to every dependency that propagates a
24+
record, and RocketPy is in the middle of adding module loggers of its own, so
25+
a competitor's score would arrive in the middle of the engine's chatter.
26+
27+
The formatter is bare ``%(message)s`` deliberately. ``basicConfig``'s default
28+
is ``levelname:name:message``, which turned ``Total reward: 7`` into
29+
``INFO:__main__:Total reward: 7``. That line is the visible result of a run
30+
and is worth keeping the way it was.
31+
32+
The threshold is set on the handler as well as on the logger, which is not
33+
redundant. A record is filtered by the level of the logger it was *emitted
34+
on*; propagation then hands it to every ancestor handler without rechecking
35+
any ancestor logger's level. So a descendant left at ``DEBUG`` sends debug
36+
records straight to this handler, and with the handler at ``NOTSET`` they
37+
reach stdout. Measured: a child at ``DEBUG`` printed its record even with the
38+
package logger at ``INFO``.
39+
40+
Only the handler this module installed is replaced, and it is closed on the
41+
way out. Clearing the logger's handlers outright would also discard a file,
42+
JSON or audit handler belonging to whatever is embedding the environment.
43+
44+
Two things this does take over, which is worth saying plainly because
45+
preserving handlers is not the same as preserving policy. Propagation is
46+
turned off, so handlers on the root logger stop receiving this package's
47+
records; that is what keeps a score from being printed twice, and it is a
48+
reasonable trade for something an entry point calls. And the logger's own
49+
threshold is lowered to ``level`` if it had none, or left where it is if the
50+
host had already set one lower, so a host's DEBUG file handler keeps working
51+
while the console still shows only ``level`` and above.
52+
53+
``level=logging.NOTSET`` is the one value that does not mean what the name
54+
suggests. On a handler it means "handle everything", but the logger gate
55+
comes first, and ``NOTSET`` on a non-root logger means "ask my ancestors",
56+
whose default is ``WARNING``. So passing it leaves ``INFO`` records dropped
57+
before they reach the console. Pass ``logging.DEBUG`` for everything. The
58+
behaviour is left as ``logging`` defines it for each object rather than
59+
special-cased here, because a single value where this function disagrees
60+
with the standard library is the worse surprise.
61+
62+
Nothing on the logger is touched until the new handler exists and has
63+
accepted ``level``, so a level ``logging`` rejects raises with the logger
64+
exactly as it was.
65+
"""
66+
package_logger = logging.getLogger(PACKAGE_LOGGER_NAME)
67+
68+
# Built and configured before the logger is touched at all. setLevel is
69+
# where logging itself decides what a level name means, and it is the call
70+
# that rejects a bad one; doing it here rather than after the swap is what
71+
# makes a rejection leave nothing half-done. It also normalises "INFO" to an
72+
# int, which the arithmetic at the bottom needs.
73+
#
74+
# Not named yet, deliberately. set_name is not configuration, it writes to
75+
# logging's process-wide handler-name registry, and close() deletes whatever
76+
# that name currently points at without checking it is the handler being
77+
# closed. Naming this one first meant the second call took the name, then
78+
# closing the old handler deleted the new one's entry: the handler stayed
79+
# attached and kept printing, so every output test passed, while
80+
# getHandlerByName returned None and an incremental dictConfig could no
81+
# longer find it. Measured. A rejected level had the same effect, which made
82+
# the atomicity this function claims untrue in the one way the logger's own
83+
# attributes do not show.
84+
handler = logging.StreamHandler(sys.stdout if stream is None else stream)
85+
handler.setFormatter(logging.Formatter("%(message)s"))
86+
handler.setLevel(level)
87+
numeric_level = handler.level
88+
89+
# getEffectiveLevel, not .level. NOTSET on a non-root logger does not mean
90+
# "no threshold", it means "ask my ancestors", so a host that set DEBUG on
91+
# the root still has an effective DEBUG here. Reading the raw attribute and
92+
# finding NOTSET, then setting INFO, raised the threshold anyway.
93+
effective_level = package_logger.getEffectiveLevel()
94+
95+
# Everything from here down mutates the logger, and none of it can fail.
96+
for existing in list(package_logger.handlers):
97+
if existing.get_name() == CONSOLE_HANDLER_NAME:
98+
package_logger.removeHandler(existing)
99+
existing.close()
100+
101+
# Named only now, once the old owner of the name has let go of it.
102+
handler.set_name(CONSOLE_HANDLER_NAME)
103+
package_logger.addHandler(handler)
104+
105+
# Never raise the threshold. A host that arranged for DEBUG here, directly
106+
# or through an ancestor, did so to feed its own handler, and moving this
107+
# logger to INFO would silence that handler even though it is left
108+
# attached. The console threshold is the one on the handler above, so
109+
# lowering this one costs nothing.
110+
package_logger.setLevel(min(effective_level, numeric_level))
111+
112+
package_logger.propagate = False
113+
return package_logger

0 commit comments

Comments
 (0)