phihung commited on
Commit
6ca8af4
·
1 Parent(s): 5f5fb32
Files changed (8) hide show
  1. README.md +2 -2
  2. fasthtml_ui.py +0 -134
  3. pyproject.toml +5 -4
  4. src/ai.rs +16 -16
  5. src/{board.rs → game.rs} +26 -9
  6. src/lib.rs +5 -5
  7. ui.py +150 -0
  8. uv.lock +48 -4
README.md CHANGED
@@ -12,9 +12,9 @@ license: apache-2.0
12
 
13
  Othello Game implemented in Rust and python.
14
 
15
- ## Run
16
 
17
  ```bash
18
  uv sync
19
- python fasthtml_ui.py
20
  ```
 
12
 
13
  Othello Game implemented in Rust and python.
14
 
15
+ ## Dev
16
 
17
  ```bash
18
  uv sync
19
+ python ui.py
20
  ```
fasthtml_ui.py DELETED
@@ -1,134 +0,0 @@
1
- from fasthtml.common import (
2
- fast_app,
3
- Div,
4
- serve,
5
- Script,
6
- Span,
7
- )
8
- from othello import Board, AlphaBeta
9
-
10
-
11
- app, rt = fast_app(
12
- hdrs=[
13
- Script(src="https://cdn.tailwindcss.com"),
14
- ],
15
- pico=False,
16
- ws_hdr=True,
17
- live=True,
18
- )
19
- board = Board.default()
20
- bot = AlphaBeta(7)
21
-
22
-
23
- @rt("/")
24
- def get():
25
- state = board.state
26
- cells = [make_cell(state.cells[i], i) for i in range(64)]
27
-
28
- return Div(
29
- Div(
30
- Div(
31
- "Black:",
32
- make_score(state.black_score, "black-score"),
33
- cls="bg-black text-white w-32 h-12 text-center content-center shadow-md",
34
- ),
35
- Div(make_status(state), cls="content-center"),
36
- Div(
37
- "White:",
38
- make_score(state.white_score, "white-score"),
39
- cls="bg-white text-black w-32 h-12 text-center content-center shadow-md",
40
- ),
41
- cls="mx-auto flex w-[32rem] justify-between",
42
- ),
43
- Div(
44
- *cells,
45
- cls="mx-auto mt-5 grid w-[32rem] grid-cols-8 gap-0 bg-green-300",
46
- hx_ext="ws",
47
- ws_connect="/wscon",
48
- ),
49
- cls="m-auto max-w-2xl bg-gray-200 p-12 mt-12",
50
- )
51
-
52
-
53
- def make_cell(v, pos):
54
- stone = None
55
- if v == "?":
56
- stone = Div(
57
- hx_trigger="click",
58
- hx_vals=f'{{"pos": {pos}}}',
59
- ws_send=True,
60
- hx_swap_oob="true",
61
- id=f"cell-{pos}",
62
- # cls="h-16 w-16 cursor-pointer bg-purple-200 hover:bg-purple-400",
63
- cls="mx-2 my-2 h-12 w-12 rounded-full cursor-pointer bg-purple-200 hover:bg-purple-300",
64
- )
65
- elif v == "B":
66
- stone = Div(
67
- cls="mx-2 my-2 h-12 w-12 rounded-full bg-black shadow-sm shadow-white"
68
- )
69
- elif v == "W":
70
- stone = Div(
71
- cls="mx-2 my-2 h-12 w-12 rounded-full bg-white shadow-sm shadow-black"
72
- )
73
- return Div(
74
- stone,
75
- id=f"cell-{pos}",
76
- cls="h-16 w-16 border border-sky-100",
77
- hx_swap_oob="true",
78
- )
79
-
80
-
81
- def make_score(v, id):
82
- return Span(v, id=id, hx_swap_oob="true")
83
-
84
-
85
- def make_status(state):
86
- status = "Black turn"
87
- if state.ended:
88
- if state.white_score > state.black_score:
89
- status = "White won!"
90
- elif state.white_score < state.black_score:
91
- status = "Black won!"
92
- else:
93
- status = "Game draw!"
94
- elif state.player == "W":
95
- status = "White turn"
96
- return Span(status, id="status", hx_swap_oob="true")
97
-
98
-
99
- @app.ws("/wscon")
100
- async def ws(pos: int, send):
101
- # Human
102
- state = await move(pos, send)
103
- if state.ended:
104
- return
105
-
106
- # Bot
107
- while True:
108
- pos = bot.find_move(board) if state.can_move else -1
109
- state = await move(pos, send)
110
- if not state.can_move and not state.ended:
111
- # Human has no move
112
- await move(-1, send)
113
- else:
114
- break
115
-
116
-
117
- async def move(pos: int, send):
118
- prev_state = board.state
119
- state = board.make_move(pos) if pos >= 0 else board.pass_move()
120
-
121
- await send(make_cell(state.cells[pos], pos))
122
- # await asyncio.sleep(1)
123
-
124
- for i, (c1, c2) in enumerate(zip(prev_state.cells, (state.cells))):
125
- if i != pos and c1 != c2:
126
- await send(make_cell(c2, i))
127
-
128
- await send(make_score(state.white_score, "white-score"))
129
- await send(make_score(state.black_score, "black-score"))
130
- await send(make_status(state))
131
- return state
132
-
133
-
134
- serve()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -5,12 +5,8 @@ description = "Add your description here"
5
  readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
8
- "maturin>=1.7.1",
9
  "python-fasthtml>=0.4.5",
10
  ]
11
- dev-dependencies = [
12
- "ruff>=0.6.2",
13
- ]
14
 
15
  [build-system]
16
  requires = ["maturin>=1,<2"]
@@ -18,3 +14,8 @@ build-backend = "maturin"
18
 
19
  [tool.uv]
20
  managed = true
 
 
 
 
 
 
5
  readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
 
8
  "python-fasthtml>=0.4.5",
9
  ]
 
 
 
10
 
11
  [build-system]
12
  requires = ["maturin>=1,<2"]
 
14
 
15
  [tool.uv]
16
  managed = true
17
+ dev-dependencies = [
18
+ "ruff>=0.6.2",
19
+ "pip>=24.2",
20
+ "maturin>=1,<2"
21
+ ]
src/ai.rs CHANGED
@@ -1,18 +1,18 @@
1
- use crate::{bits::BitBoard, board::Board, consts::ALPHA_BETA_SCORES};
2
  use pyo3::prelude::*;
3
  use rand::prelude::*;
4
 
5
  pub trait AI {
6
- fn run(&self, board: &Board) -> usize;
7
  }
8
 
9
  #[pyclass]
10
- pub struct AlphaBeta {
11
  depth: usize,
12
  }
13
 
14
- impl AI for AlphaBeta {
15
- fn run(&self, board: &Board) -> usize {
16
  let (_, move_) = self.do_search(
17
  &mut rand::thread_rng(),
18
  &board.board,
@@ -25,18 +25,19 @@ impl AI for AlphaBeta {
25
  }
26
 
27
  #[pymethods]
28
- impl AlphaBeta {
29
  #[new]
30
  pub fn new(depth: usize) -> Self {
31
  Self { depth }
32
  }
33
 
34
- fn find_move(&self, board: &Board) -> usize {
35
- self.run(board)
 
36
  }
37
  }
38
 
39
- impl AlphaBeta {
40
  fn do_search(
41
  &self,
42
  rng: &mut ThreadRng,
@@ -109,21 +110,21 @@ impl AlphaBeta {
109
 
110
  #[cfg(test)]
111
  mod tests {
112
- use crate::{bits::BitBoard, board::Board};
113
 
114
- use super::{AlphaBeta, AI};
115
 
116
  #[test]
117
  fn test() {
118
- let mut b = Board::default();
119
- let ai = [AlphaBeta { depth: 5 }, AlphaBeta { depth: 6 }];
120
 
121
  for i in 0..80 {
122
  if b.available_moves().is_empty() {
123
  b.pass_move();
124
  println!("PASS");
125
  } else {
126
- let move_ = ai[i % 2].run(&b);
127
  let state = b.make_move(move_);
128
  println!("{}\n---\n", b.__repr__());
129
  if state.ended {
@@ -131,12 +132,11 @@ mod tests {
131
  }
132
  }
133
  }
134
- assert!(false);
135
  }
136
 
137
  #[test]
138
  fn test_evaluate() {
139
- let ai = AlphaBeta { depth: 3 };
140
  let b = BitBoard(3, 12);
141
  println!("{:?}", b);
142
  assert_eq!(ai.evaluate(&BitBoard(3, 12)), 245);
 
1
+ use crate::{bits::BitBoard, consts::ALPHA_BETA_SCORES, game::Game};
2
  use pyo3::prelude::*;
3
  use rand::prelude::*;
4
 
5
  pub trait AI {
6
+ fn find_move(&self, board: &Game) -> usize;
7
  }
8
 
9
  #[pyclass]
10
+ pub struct AlphaBetaBot {
11
  depth: usize,
12
  }
13
 
14
+ impl AI for AlphaBetaBot {
15
+ fn find_move(&self, board: &Game) -> usize {
16
  let (_, move_) = self.do_search(
17
  &mut rand::thread_rng(),
18
  &board.board,
 
25
  }
26
 
27
  #[pymethods]
28
+ impl AlphaBetaBot {
29
  #[new]
30
  pub fn new(depth: usize) -> Self {
31
  Self { depth }
32
  }
33
 
34
+ #[pyo3(name = "find_move")]
35
+ fn run(&self, board: &Game) -> usize {
36
+ self.find_move(board)
37
  }
38
  }
39
 
40
+ impl AlphaBetaBot {
41
  fn do_search(
42
  &self,
43
  rng: &mut ThreadRng,
 
110
 
111
  #[cfg(test)]
112
  mod tests {
113
+ use crate::{bits::BitBoard, game::Game};
114
 
115
+ use super::{AlphaBetaBot, AI};
116
 
117
  #[test]
118
  fn test() {
119
+ let mut b = Game::default();
120
+ let ai = [AlphaBetaBot { depth: 5 }, AlphaBetaBot { depth: 6 }];
121
 
122
  for i in 0..80 {
123
  if b.available_moves().is_empty() {
124
  b.pass_move();
125
  println!("PASS");
126
  } else {
127
+ let move_ = ai[i % 2].find_move(&b);
128
  let state = b.make_move(move_);
129
  println!("{}\n---\n", b.__repr__());
130
  if state.ended {
 
132
  }
133
  }
134
  }
 
135
  }
136
 
137
  #[test]
138
  fn test_evaluate() {
139
+ let ai = AlphaBetaBot { depth: 3 };
140
  let b = BitBoard(3, 12);
141
  println!("{:?}", b);
142
  assert_eq!(ai.evaluate(&BitBoard(3, 12)), 245);
src/{board.rs → game.rs} RENAMED
@@ -6,7 +6,7 @@ const PLAYERS: [char; 2] = ['B', 'W'];
6
 
7
  #[pyclass]
8
  #[derive(Clone)]
9
- pub struct Board {
10
  pub board: BitBoard,
11
  pub current_player: usize, // 0 or 1
12
  #[pyo3(get)]
@@ -32,7 +32,7 @@ impl State {
32
  }
33
 
34
  #[pymethods]
35
- impl Board {
36
  #[staticmethod]
37
  pub fn default() -> Self {
38
  let board = BitBoard::default();
@@ -75,9 +75,9 @@ impl Board {
75
  }
76
  if state.ended {
77
  s.push_str(match state.black_score.cmp(&state.white_score) {
78
- Equal => "Game draw!",
79
- Less => "White won!",
80
- Greater => "Black won!",
81
  });
82
  } else {
83
  s.push_str(&format!(
@@ -90,7 +90,7 @@ impl Board {
90
  }
91
  }
92
 
93
- impl Board {
94
  fn compute_state(board: &BitBoard, current_player: usize) -> State {
95
  let (cnt0, cnt1) = board.count();
96
  let moves = board.available_moves();
@@ -121,11 +121,12 @@ impl Board {
121
 
122
  #[cfg(test)]
123
  mod tests {
124
- use super::Board;
 
125
 
126
  #[test]
127
- fn default_test() {
128
- let mut b = Board::default();
129
 
130
  assert_eq!(b.available_moves(), &[19, 26, 37, 44]);
131
  b.make_move(44);
@@ -139,4 +140,20 @@ mod tests {
139
 
140
  assert_eq!(b.__repr__(), "\n........\n........\n..?????.\n...WWW..\n...BB...\n....B...\n........\n........\nB to play. Available moves: [18, 19, 20, 21, 22]");
141
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  }
 
6
 
7
  #[pyclass]
8
  #[derive(Clone)]
9
+ pub struct Game {
10
  pub board: BitBoard,
11
  pub current_player: usize, // 0 or 1
12
  #[pyo3(get)]
 
32
  }
33
 
34
  #[pymethods]
35
+ impl Game {
36
  #[staticmethod]
37
  pub fn default() -> Self {
38
  let board = BitBoard::default();
 
75
  }
76
  if state.ended {
77
  s.push_str(match state.black_score.cmp(&state.white_score) {
78
+ Equal => "\nGame draw!",
79
+ Less => "\nWhite won!",
80
+ Greater => "\nBlack won!",
81
  });
82
  } else {
83
  s.push_str(&format!(
 
90
  }
91
  }
92
 
93
+ impl Game {
94
  fn compute_state(board: &BitBoard, current_player: usize) -> State {
95
  let (cnt0, cnt1) = board.count();
96
  let moves = board.available_moves();
 
121
 
122
  #[cfg(test)]
123
  mod tests {
124
+ use super::Game;
125
+ use crate::bits::BitBoard;
126
 
127
  #[test]
128
+ fn test_1() {
129
+ let mut b = Game::default();
130
 
131
  assert_eq!(b.available_moves(), &[19, 26, 37, 44]);
132
  b.make_move(44);
 
140
 
141
  assert_eq!(b.__repr__(), "\n........\n........\n..?????.\n...WWW..\n...BB...\n....B...\n........\n........\nB to play. Available moves: [18, 19, 20, 21, 22]");
142
  }
143
+
144
+ #[test]
145
+ fn test_2() {
146
+ let b = BitBoard(2, 1);
147
+ let mut g = Game {
148
+ current_player: 0,
149
+ state: Game::compute_state(&b, 0),
150
+ board: b,
151
+ };
152
+ assert_eq!(g.state.can_move, false);
153
+ g.pass_move();
154
+ assert_eq!(g.state.can_move, true);
155
+ g.make_move(2);
156
+ assert_eq!(g.state.ended, true);
157
+ assert_eq!(g.state.white_score, 3);
158
+ }
159
  }
src/lib.rs CHANGED
@@ -1,18 +1,18 @@
1
- use ai::AlphaBeta;
2
  use bits::BitBoard;
3
- use board::{Board, State};
4
  use pyo3::prelude::*;
5
 
6
  pub mod ai;
7
  pub mod bits;
8
- pub mod board;
9
  pub mod consts;
 
10
 
11
  #[pymodule]
12
  fn othello(m: &Bound<'_, PyModule>) -> PyResult<()> {
13
  m.add_class::<BitBoard>()?;
14
- m.add_class::<Board>()?;
15
- m.add_class::<AlphaBeta>()?;
16
  m.add_class::<State>()?;
17
  Ok(())
18
  }
 
1
+ use ai::AlphaBetaBot;
2
  use bits::BitBoard;
3
+ use game::{Game, State};
4
  use pyo3::prelude::*;
5
 
6
  pub mod ai;
7
  pub mod bits;
 
8
  pub mod consts;
9
+ pub mod game;
10
 
11
  #[pymodule]
12
  fn othello(m: &Bound<'_, PyModule>) -> PyResult<()> {
13
  m.add_class::<BitBoard>()?;
14
+ m.add_class::<Game>()?;
15
+ m.add_class::<AlphaBetaBot>()?;
16
  m.add_class::<State>()?;
17
  Ok(())
18
  }
ui.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from uuid import uuid1
2
+ from fasthtml.common import (
3
+ fast_app,
4
+ Div,
5
+ serve,
6
+ Script,
7
+ Span,
8
+ cookie,
9
+ A,
10
+ RedirectResponse,
11
+ )
12
+ from othello import Game, AlphaBetaBot
13
+
14
+
15
+ app, rt = fast_app(
16
+ hdrs=[Script(src="https://cdn.tailwindcss.com")],
17
+ pico=False,
18
+ ws_hdr=True,
19
+ live=True,
20
+ )
21
+
22
+ games = {}
23
+ bot = AlphaBetaBot(7)
24
+
25
+
26
+ @rt("/")
27
+ def get(uuid: str = None):
28
+ if not uuid:
29
+ uuid = str(uuid1())
30
+
31
+ if uuid not in games:
32
+ games[uuid] = Game.default()
33
+
34
+ return cookie("uuid", uuid), make_app(uuid)
35
+
36
+
37
+ @app.get("/new")
38
+ def new(uuid: str = None):
39
+ if uuid is not None:
40
+ del games[uuid]
41
+ return RedirectResponse("/")
42
+
43
+
44
+ def make_app(uuid):
45
+ state = games[uuid].state
46
+ return Div(
47
+ Div(
48
+ make_status_bar(state),
49
+ Div(
50
+ *(make_cell(state.cells[i], i, uuid) for i in range(64)),
51
+ cls="grid grid-cols-8 gap-0 bg-green-300 mb-5 lg:mt-5",
52
+ hx_ext="ws",
53
+ ws_connect="/wscon",
54
+ ),
55
+ A("New Game", href="/new", cls="rounded-md bg-teal-600 mt-5 px-5 py-2"),
56
+ cls="m-auto w-fit",
57
+ ),
58
+ cls="m-auto max-w-2xl bg-gray-200 lg:pt-12 pb-12 lg:mt-12",
59
+ )
60
+
61
+
62
+ def make_cell(v, pos, uuid):
63
+ style = "m-2 size-8 lg:size-12 rounded-full"
64
+ stone = None
65
+ if v == "?":
66
+ stone = Div(
67
+ hx_trigger="click",
68
+ hx_vals=f'{{"pos": {pos}, "uuid": "{uuid}"}}',
69
+ ws_send=True,
70
+ cls=f"{style} cursor-pointer bg-purple-200 hover:bg-purple-300",
71
+ )
72
+ elif v == "B":
73
+ stone = Div(cls=f"{style} shadow-sm bg-black shadow-white")
74
+ elif v == "W":
75
+ stone = Div(cls=f"{style} shadow-sm bg-white shadow-black")
76
+ return Div(
77
+ stone,
78
+ id=f"cell-{pos}",
79
+ cls="size-12 xl:size-16 border border-sky-100",
80
+ hx_swap_oob="true",
81
+ )
82
+
83
+
84
+ def make_status_bar(state):
85
+ status = get_status(state)
86
+ return Div(
87
+ Div(
88
+ f"Black: {state.black_score}",
89
+ cls="bg-black text-white w-32 h-12 text-center content-center",
90
+ ),
91
+ Div(Span(status, id="status", hx_swap_oob="true"), cls="content-center"),
92
+ Div(
93
+ f"White: {state.white_score}",
94
+ cls="bg-white text-black w-32 h-12 text-center content-center",
95
+ ),
96
+ cls="flex justify-between",
97
+ id="status-bar",
98
+ hx_swap_oob="true",
99
+ )
100
+
101
+
102
+ def get_status(state):
103
+ status = "Black turn"
104
+ if state.ended:
105
+ if state.white_score > state.black_score:
106
+ status = "White won!"
107
+ elif state.white_score < state.black_score:
108
+ status = "Black won!"
109
+ else:
110
+ status = "Game draw!"
111
+ elif state.player == "W":
112
+ status = "White turn"
113
+ return Span(status, id="status", hx_swap_oob="true")
114
+
115
+
116
+ @app.ws("/wscon")
117
+ async def ws(uuid: str, pos: int, send):
118
+ game = games[uuid]
119
+
120
+ async def play(pos: int):
121
+ prev_state = game.state
122
+ state = game.make_move(pos) if pos >= 0 else game.pass_move()
123
+
124
+ await send(make_cell(state.cells[pos], pos, uuid))
125
+ # await asyncio.sleep(1)
126
+
127
+ for i, (c1, c2) in enumerate(zip(prev_state.cells, state.cells)):
128
+ if i != pos and c1 != c2:
129
+ await send(make_cell(c2, i, uuid))
130
+ await send(make_status_bar(state))
131
+ return state
132
+
133
+ # Human
134
+ state = await play(pos)
135
+ if state.ended:
136
+ return
137
+
138
+ # Bot
139
+ while True:
140
+ pos = bot.find_move(game) if state.can_move else -1
141
+ state = await play(pos)
142
+ if not state.can_move and not state.ended:
143
+ # Human has no move
144
+ state = await play(-1)
145
+ assert pos != -1 and state.can_move and not state.ended
146
+ else:
147
+ break
148
+
149
+
150
+ serve()
uv.lock CHANGED
@@ -193,14 +193,24 @@ name = "othello"
193
  version = "0.1.0"
194
  source = { editable = "." }
195
  dependencies = [
196
- { name = "maturin" },
197
  { name = "python-fasthtml" },
198
  ]
199
 
 
 
 
 
 
 
 
200
  [package.metadata]
201
- requires-dist = [
202
- { name = "maturin", specifier = ">=1.7.1" },
203
- { name = "python-fasthtml", specifier = ">=0.4.5" },
 
 
 
 
204
  ]
205
 
206
  [[package]]
@@ -212,6 +222,15 @@ wheels = [
212
  { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 },
213
  ]
214
 
 
 
 
 
 
 
 
 
 
215
  [[package]]
216
  name = "python-dateutil"
217
  version = "2.9.0.post0"
@@ -298,6 +317,31 @@ wheels = [
298
  { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
299
  ]
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  [[package]]
302
  name = "six"
303
  version = "1.16.0"
 
193
  version = "0.1.0"
194
  source = { editable = "." }
195
  dependencies = [
 
196
  { name = "python-fasthtml" },
197
  ]
198
 
199
+ [package.dev-dependencies]
200
+ dev = [
201
+ { name = "maturin" },
202
+ { name = "pip" },
203
+ { name = "ruff" },
204
+ ]
205
+
206
  [package.metadata]
207
+ requires-dist = [{ name = "python-fasthtml", specifier = ">=0.4.5" }]
208
+
209
+ [package.metadata.requires-dev]
210
+ dev = [
211
+ { name = "maturin", specifier = ">=1,<2" },
212
+ { name = "pip", specifier = ">=24.2" },
213
+ { name = "ruff", specifier = ">=0.6.2" },
214
  ]
215
 
216
  [[package]]
 
222
  { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 },
223
  ]
224
 
225
+ [[package]]
226
+ name = "pip"
227
+ version = "24.2"
228
+ source = { registry = "https://pypi.org/simple" }
229
+ sdist = { url = "https://files.pythonhosted.org/packages/4d/87/fb90046e096a03aeab235e139436b3fe804cdd447ed2093b0d70eba3f7f8/pip-24.2.tar.gz", hash = "sha256:5b5e490b5e9cb275c879595064adce9ebd31b854e3e803740b72f9ccf34a45b8", size = 1922041 }
230
+ wheels = [
231
+ { url = "https://files.pythonhosted.org/packages/d4/55/90db48d85f7689ec6f81c0db0622d704306c5284850383c090e6c7195a5c/pip-24.2-py3-none-any.whl", hash = "sha256:2cd581cf58ab7fcfca4ce8efa6dcacd0de5bf8d0a3eb9ec927e07405f4d9e2a2", size = 1815170 },
232
+ ]
233
+
234
  [[package]]
235
  name = "python-dateutil"
236
  version = "2.9.0.post0"
 
317
  { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
318
  ]
319
 
320
+ [[package]]
321
+ name = "ruff"
322
+ version = "0.6.2"
323
+ source = { registry = "https://pypi.org/simple" }
324
+ sdist = { url = "https://files.pythonhosted.org/packages/23/f4/279d044f66b79261fd37df76bf72b64471afab5d3b7906a01499c4451910/ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be", size = 2460281 }
325
+ wheels = [
326
+ { url = "https://files.pythonhosted.org/packages/72/4b/47dd7a69287afb4069fa42c198e899463605460a58120196711bfcf0446b/ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c", size = 9695871 },
327
+ { url = "https://files.pythonhosted.org/packages/ae/c3/8aac62ac4638c14a740ee76a755a925f2d0d04580ab790a9887accb729f6/ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570", size = 9459354 },
328
+ { url = "https://files.pythonhosted.org/packages/2f/cf/77fbd8d4617b9b9c503f9bffb8552c4e3ea1a58dc36975e7a9104ffb0f85/ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158", size = 9163871 },
329
+ { url = "https://files.pythonhosted.org/packages/05/1c/765192bab32b79efbb498b06f0b9dcb3629112b53b8777ae1d19b8209e09/ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534", size = 10096250 },
330
+ { url = "https://files.pythonhosted.org/packages/08/d0/86f3cb0f6934c99f759c232984a5204d67a26745cad2d9edff6248adf7d2/ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b", size = 9475376 },
331
+ { url = "https://files.pythonhosted.org/packages/cd/cc/4c8d0e225b559a3fae6092ec310d7150d3b02b4669e9223f783ef64d82c0/ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d", size = 10295634 },
332
+ { url = "https://files.pythonhosted.org/packages/db/96/d2699cfb1bb5a01c68122af43454c76c31331e1c8a9bd97d653d7c82524b/ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66", size = 11024941 },
333
+ { url = "https://files.pythonhosted.org/packages/8b/a9/6ecd66af8929e0f2a1ed308a4137f3521789f28f0eb97d32c2ca3aa7000c/ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8", size = 10606894 },
334
+ { url = "https://files.pythonhosted.org/packages/e4/73/2ee4cd19f44992fedac1cc6db9e3d825966072f6dcbd4032f21cbd063170/ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1", size = 11552886 },
335
+ { url = "https://files.pythonhosted.org/packages/60/4c/c0f1cd35ce4a93c54a6bb1ee6934a3a205fa02198dd076678193853ceea1/ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1", size = 10264945 },
336
+ { url = "https://files.pythonhosted.org/packages/c4/89/e45c9359b9cdd4245512ea2b9f2bb128a997feaa5f726fc9e8c7a66afadf/ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23", size = 10100007 },
337
+ { url = "https://files.pythonhosted.org/packages/06/74/0bd4e0a7ed5f6908df87892f9bf60a2356c0fd74102d8097298bd9b4f346/ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a", size = 9559267 },
338
+ { url = "https://files.pythonhosted.org/packages/54/03/3dc6dc9419f276f05805bf888c279e3e0b631284abd548d9e87cebb93aec/ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c", size = 9905304 },
339
+ { url = "https://files.pythonhosted.org/packages/5c/5b/d6a72a6a6bbf097c09de468326ef5fa1c9e7aa5e6e45979bc0d984b0dbe7/ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56", size = 10341480 },
340
+ { url = "https://files.pythonhosted.org/packages/79/a9/0f2f21fe15ba537c46598f96aa9ae4a3d4b9ec64926664617ca6a8c772f4/ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da", size = 7961901 },
341
+ { url = "https://files.pythonhosted.org/packages/b0/80/fff12ffe11853d9f4ea3e5221e6dd2e93640a161c05c9579833e09ad40a7/ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2", size = 8783320 },
342
+ { url = "https://files.pythonhosted.org/packages/56/91/577cdd64cce5e74d3f8b5ecb93f29566def569c741eb008aed4f331ef821/ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9", size = 8225886 },
343
+ ]
344
+
345
  [[package]]
346
  name = "six"
347
  version = "1.16.0"